diff --git a/.github/workflows/ci-horizon.yml b/.github/workflows/ci-horizon.yml new file mode 100644 index 0000000..207e620 --- /dev/null +++ b/.github/workflows/ci-horizon.yml @@ -0,0 +1,53 @@ +name: CI Horizon + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-horizon-${{ github.ref }} + cancel-in-progress: true + +jobs: + wrapper-validation: + name: Validate Gradle Wrapper + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Validate Wrapper + uses: gradle/wrapper-validation-action@v2 + + horizon-build: + name: Build Horizon flavors + runs-on: ubuntu-latest + needs: wrapper-validation + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/gradle-build-action@v2 + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install required SDK packages + run: | + sdkmanager --install \ + "platform-tools" \ + "platforms;android-34" \ + "build-tools;34.0.0" + yes | sdkmanager --licenses + + - name: Build Horizon variants + run: ./gradlew --stacktrace --no-daemon :openiap:assembleHorizonDebug :Example:assembleHorizonDebug diff --git a/.vscode/launch.json b/.vscode/launch.json index 2605d54..f1f444b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,13 +2,24 @@ "version": "0.2.0", "configurations": [ { - "name": "πŸš€ Android: Run Example (install & start)", + "name": "πŸš€ Android: Run Example (Play)", "type": "node", "request": "launch", "runtimeExecutable": "bash", "runtimeArgs": [ "-lc", - "./gradlew :Example:installDebug && adb shell am start -n dev.hyo.martie/.MainActivity" + "./gradlew :Example:installPlayDebug && adb shell am start -n dev.hyo.martie/.MainActivity" + ], + "console": "integratedTerminal" + }, + { + "name": "πŸš€ Android: Run Example (Horizon)", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-lc", + "./gradlew -PEXAMPLE_OPENIAP_STORE=horizon \"-PEXAMPLE_HORIZON_APP_ID=${input:horizonAppId}\" :Example:installHorizonDebug && adb shell am start -n dev.hyo.martie/.MainActivity" ], "console": "integratedTerminal" }, @@ -24,13 +35,24 @@ "console": "integratedTerminal" }, { - "name": "🧱 Android: Assemble Example (debug)", + "name": "🧱 Android: Assemble Example (Play Debug)", + "type": "node", + "request": "launch", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-lc", + "./gradlew :Example:assemblePlayDebug" + ], + "console": "integratedTerminal" + }, + { + "name": "🧱 Android: Assemble Example (Horizon Debug)", "type": "node", "request": "launch", "runtimeExecutable": "bash", "runtimeArgs": [ "-lc", - "./gradlew :Example:assembleDebug" + "./gradlew -PEXAMPLE_OPENIAP_STORE=horizon \"-PEXAMPLE_HORIZON_APP_ID=${input:horizonAppId}\" :Example:assembleHorizonDebug" ], "console": "integratedTerminal" }, @@ -67,5 +89,13 @@ ], "console": "integratedTerminal" } + ], + "inputs": [ + { + "id": "horizonAppId", + "type": "promptString", + "description": "Enter Horizon App ID (Meta) used when building the example", + "default": "" + } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 9168dbe..233ca03 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,7 +35,6 @@ "**/node_modules/": true }, "gradle.nestedProjects": true, - "gradle.javaDebug": true, "typescript.validate.enable": false, "javascript.validate.enable": false, "typescript.tsc.autoDetect": "off", @@ -44,6 +43,7 @@ "billingclient", "gson", "hyodotdev", + "martie", "openiap", "skus" ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9252fdd..e66c4a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,17 +22,32 @@ cd openiap-google # Open in Android Studio (recommended) ./scripts/open-android-studio.sh -# Or build from CLI -./gradlew :openiap:assemble +# Or build from CLI (Play flavor) +./gradlew :openiap:assemblePlayDebug # Run unit tests for the library module ./gradlew :openiap:test -# (Optional) Install and run the Example app -./gradlew :Example:installDebug +# (Optional) Install and run the Example app (Play flavor) +./gradlew :Example:installPlayDebug adb shell am start -n dev.hyo.martie/.MainActivity ``` +### Horizon flavor testing + +The Horizon build uses a dedicated product flavor that bundles Meta's billing compatibility SDK. + +```bash +# Assemble the Horizon flavor of the library +./gradlew :openiap:assembleHorizonDebug + +# Install the sample app (replace APP_ID with your Meta Horizon identifier) +./gradlew -PEXAMPLE_OPENIAP_STORE=horizon "-PEXAMPLE_HORIZON_APP_ID=APP_ID" :Example:installHorizonDebug +adb shell am start -n dev.hyo.martie/.MainActivity +``` + +With flavors enabled, Gradle no longer creates the generic `installDebug`/`assembleDebug` tasksβ€”run the flavor-specific tasks explicitly in scripts, CI pipelines, and IDE run configurations. + ## Generated Types - All GraphQL models in `openiap/src/main/java/dev/hyo/openiap/Types.kt` are generated from the [`hyodotdev/openiap-gql`](https://github.com/hyodotdev/openiap-gql) repository. When you update API behavior, adjust the upstream type generator first so the Kotlin output stays in sync across platforms. diff --git a/Example/build.gradle.kts b/Example/build.gradle.kts index 7acf064..2fbdec4 100644 --- a/Example/build.gradle.kts +++ b/Example/build.gradle.kts @@ -17,6 +17,28 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true + val appId = (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?) + ?: (project.findProperty("EXAMPLE_OPENIAP_APP_ID") as String?) + ?: "" + buildConfigField("String", "HORIZON_APP_ID", "\"${appId}\"") + } + + flavorDimensions += "store" + + productFlavors { + val storeOverride = (project.findProperty("EXAMPLE_OPENIAP_STORE") as String?) + + create("play") { + dimension = "store" + val value = storeOverride ?: "play" + buildConfigField("String", "OPENIAP_STORE", "\"${value}\"") + } + + create("horizon") { + dimension = "store" + val value = storeOverride ?: "horizon" + buildConfigField("String", "OPENIAP_STORE", "\"${value}\"") + } } buildTypes { @@ -44,6 +66,7 @@ android { buildFeatures { compose = true + buildConfig = true } packaging { diff --git a/Example/src/main/java/dev/hyo/martie/Constants.kt b/Example/src/main/java/dev/hyo/martie/Constants.kt index 080261b..0fc0ac2 100644 --- a/Example/src/main/java/dev/hyo/martie/Constants.kt +++ b/Example/src/main/java/dev/hyo/martie/Constants.kt @@ -1,7 +1,6 @@ package dev.hyo.martie object IapConstants { - // App-defined SKU lists val INAPP_SKUS = listOf( "dev.hyo.martie.10bulbs", "dev.hyo.martie.30bulbs", @@ -17,4 +16,3 @@ object IapConstants { const val PREMIUM_MONTHLY_BASE_PLAN = "premium" // Monthly base plan const val PREMIUM_YEARLY_BASE_PLAN = "premium-year" // Yearly base plan } - diff --git a/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt b/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt index 808b78a..ab5854e 100644 --- a/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt +++ b/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt @@ -54,7 +54,12 @@ fun PurchaseFlowScreen( val activity = remember(context) { context.findActivity() } val uiScope = rememberCoroutineScope() val appContext = remember(context) { context.applicationContext } - val iapStore = storeParam ?: remember(appContext) { OpenIapStore(appContext) } + val iapStore = storeParam ?: remember(appContext) { + val storeKey = dev.hyo.martie.BuildConfig.OPENIAP_STORE + val appId = dev.hyo.martie.BuildConfig.HORIZON_APP_ID + runCatching { OpenIapStore(appContext, storeKey, appId) } + .getOrElse { OpenIapStore(appContext, "auto", appId) } + } val products by iapStore.products.collectAsState() val purchases by iapStore.availablePurchases.collectAsState() val androidProducts = remember(products) { products.filterIsInstance() } diff --git a/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt index fd19349..f2c0b39 100644 --- a/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt +++ b/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt @@ -84,7 +84,12 @@ fun SubscriptionFlowScreen( // SharedPreferences to track current offer (necessary since Google doesn't provide offer info) val prefs = remember { context.getSharedPreferences(SUBSCRIPTION_PREFS_NAME, Context.MODE_PRIVATE) } - val iapStore = storeParam ?: remember(appContext) { OpenIapStore(appContext) } + val iapStore = storeParam ?: remember(appContext) { + val storeKey = dev.hyo.martie.BuildConfig.OPENIAP_STORE + val appId = dev.hyo.martie.BuildConfig.HORIZON_APP_ID + runCatching { OpenIapStore(appContext, storeKey, appId) } + .getOrElse { OpenIapStore(appContext, "auto", appId) } + } val products by iapStore.products.collectAsState() val subscriptions by iapStore.subscriptions.collectAsState() val purchases by iapStore.availablePurchases.collectAsState() diff --git a/README.md b/README.md index 6b635cb..7f5d1d7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ OpenIAP GMS is a modern, type-safe Kotlin library that simplifies Google Play in - πŸ” **Google Play Billing v8** - Latest billing library with enhanced security - ⚑ **Kotlin Coroutines** - Modern async/await API - 🎯 **Type Safe** - Full Kotlin type safety with sealed classes +- πŸ₯½ **Meta Horizon OS Support** - Optional compatibility SDK integration alongside Play Billing - πŸ”„ **Real-time Events** - Purchase update and error listeners - 🧡 **Thread Safe** - Concurrent operations with proper synchronization - πŸ“± **Easy Integration** - Simple singleton pattern with context management @@ -52,6 +53,21 @@ dependencies { } ``` +### Optional provider configuration + +Set the target billing provider via `BuildConfig` fields (default is `play`). The library will also auto-detect Horizon hardware when `auto` is supplied. + +```kotlin +android { + defaultConfig { + buildConfigField("String", "OPENIAP_STORE", "\"auto\"") // play | horizon | auto + buildConfigField("String", "HORIZON_APP_ID", "\"YOUR_APP_ID\"") + } +} +``` + +The example app reads the same values via `EXAMPLE_OPENIAP_STORE` / `EXAMPLE_HORIZON_APP_ID` Gradle properties for quick testing. + Or `build.gradle`: ```groovy @@ -156,6 +172,21 @@ class MainActivity : AppCompatActivity() { } ``` +## πŸ₯½ Testing on Meta Horizon + +The library exposes a dedicated `horizon` product flavor that bundles Meta's billing compatibility SDK. Build and install it with: + +```bash +# Compile the Horizon flavor of the library +./gradlew :openiap:assembleHorizonDebug + +# Install the sample app (replace APP_ID with your Horizon app id) +./gradlew -PEXAMPLE_OPENIAP_STORE=horizon "-PEXAMPLE_HORIZON_APP_ID=APP_ID" :Example:installHorizonDebug +adb shell am start -n dev.hyo.martie/.MainActivity +``` + +For standard Google Play workflows, run the matching `play` tasks (`:openiap:assemblePlayDebug`, `:Example:installPlayDebug`). Flavors remove the generic `installDebug` task, so always target the desired flavor explicitly when using the CLI, CI, or IDE run configurations. + ## πŸ“š API Reference ### Core Methods diff --git a/openiap/build.gradle.kts b/openiap/build.gradle.kts index c58fb4c..2e94973 100644 --- a/openiap/build.gradle.kts +++ b/openiap/build.gradle.kts @@ -5,6 +5,8 @@ plugins { id("com.vanniktech.maven.publish") } +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary + // Resolve version from either 'openIapVersion' or 'OPENIAP_VERSION' or fallback val openIapVersion: String = (project.findProperty("openIapVersion") @@ -20,6 +22,21 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") + buildConfigField("String", "OPENIAP_STORE", "\"play\"") + buildConfigField("String", "HORIZON_APP_ID", "\"\"") + } + + flavorDimensions += "store" + + productFlavors { + create("play") { + dimension = "store" + buildConfigField("String", "OPENIAP_STORE", "\"play\"") + } + create("horizon") { + dimension = "store" + buildConfigField("String", "OPENIAP_STORE", "\"horizon\"") + } } buildTypes { @@ -44,7 +61,9 @@ android { // Enable Compose for composables in this library (IapContext) buildFeatures { compose = true + buildConfig = true } + } dependencies { @@ -53,6 +72,9 @@ dependencies { // Google Play Billing Library (align with app/lib v8) api("com.android.billingclient:billing-ktx:8.0.0") + + // Meta Horizon Billing compatibility client (only needed on the horizon flavor) + add("horizonImplementation", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") // Kotlin Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") @@ -83,6 +105,7 @@ mavenPublishing { // Use the new Central Portal publishing which avoids Nexus staging profile lookups. publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) signAllPublications() + configure(AndroidSingleVariantLibrary(variant = "playRelease", sourcesJar = true, publishJavadocJar = true)) pom { name.set("OpenIAP GMS") diff --git a/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt b/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt new file mode 100644 index 0000000..a4cc257 --- /dev/null +++ b/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt @@ -0,0 +1,512 @@ +package dev.hyo.openiap.horizon + +import android.app.Activity +import android.content.Context +import android.util.Log +import com.meta.horizon.billingclient.api.AcknowledgePurchaseParams +import com.meta.horizon.billingclient.api.BillingClient +import com.meta.horizon.billingclient.api.BillingClientStateListener +import com.meta.horizon.billingclient.api.BillingFlowParams +import com.meta.horizon.billingclient.api.BillingResult +import com.meta.horizon.billingclient.api.ConsumeParams +import com.meta.horizon.billingclient.api.GetBillingConfigParams +import com.meta.horizon.billingclient.api.PendingPurchasesParams +import com.meta.horizon.billingclient.api.ProductDetails as HorizonProductDetails +import com.meta.horizon.billingclient.api.Purchase as HorizonPurchase +import com.meta.horizon.billingclient.api.PurchasesUpdatedListener +import com.meta.horizon.billingclient.api.QueryProductDetailsParams +import com.meta.horizon.billingclient.api.QueryPurchasesParams +import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.FetchProductsResult +import dev.hyo.openiap.FetchProductsResultProducts +import dev.hyo.openiap.FetchProductsResultSubscriptions +import dev.hyo.openiap.IapPlatform +import dev.hyo.openiap.MutationAcknowledgePurchaseAndroidHandler +import dev.hyo.openiap.MutationConsumePurchaseAndroidHandler +import dev.hyo.openiap.MutationDeepLinkToSubscriptionsHandler +import dev.hyo.openiap.MutationEndConnectionHandler +import dev.hyo.openiap.MutationFinishTransactionHandler +import dev.hyo.openiap.MutationHandlers +import dev.hyo.openiap.MutationInitConnectionHandler +import dev.hyo.openiap.MutationRequestPurchaseHandler +import dev.hyo.openiap.MutationRestorePurchasesHandler +import dev.hyo.openiap.MutationValidateReceiptHandler +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapLog +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.Product +import dev.hyo.openiap.ProductAndroid +import dev.hyo.openiap.ProductQueryType +import dev.hyo.openiap.ProductSubscriptionAndroid +import dev.hyo.openiap.ProductType +import dev.hyo.openiap.Purchase +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.PurchaseInput +import dev.hyo.openiap.QueryFetchProductsHandler +import dev.hyo.openiap.QueryGetActiveSubscriptionsHandler +import dev.hyo.openiap.QueryGetAvailablePurchasesHandler +import dev.hyo.openiap.QueryHandlers +import dev.hyo.openiap.QueryHasActiveSubscriptionsHandler +import dev.hyo.openiap.ReceiptValidationProps +import dev.hyo.openiap.RequestPurchaseResultPurchase +import dev.hyo.openiap.RequestPurchaseResultPurchases +import dev.hyo.openiap.RequestPurchaseProps +import dev.hyo.openiap.SubscriptionHandlers +import dev.hyo.openiap.SubscriptionPurchaseErrorHandler +import dev.hyo.openiap.SubscriptionPurchaseUpdatedHandler +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener +import dev.hyo.openiap.helpers.onPurchaseError +import dev.hyo.openiap.helpers.onPurchaseUpdated +import dev.hyo.openiap.helpers.toAndroidPurchaseArgs +import dev.hyo.openiap.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.toActiveSubscription +import dev.hyo.openiap.utils.toProduct +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.lang.ref.WeakReference +import kotlin.coroutines.resume + +private const val TAG = "OpenIapHorizonModule" + +class OpenIapHorizonModule( + private val context: Context, + private val appId: String? = null +) : OpenIapProtocol, PurchasesUpdatedListener { + + private var billingClient: BillingClient? = null + private var currentActivityRef: WeakReference? = null + private var currentPurchaseCallback: ((Result>) -> Unit)? = null + + private val purchaseUpdateListeners = mutableSetOf() + private val purchaseErrorListeners = mutableSetOf() + + init { + buildBillingClient() + } + + override fun setActivity(activity: Activity?) { + currentActivityRef = activity?.let { WeakReference(it) } + } + + override val initConnection: MutationInitConnectionHandler = { + withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + val client = billingClient ?: run { + if (continuation.isActive) continuation.resume(false) + return@suspendCancellableCoroutine + } + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + val ok = result.responseCode == BillingClient.BillingResponseCode.OK + if (!ok) { + OpenIapLog.w("Horizon setup failed: ${result.debugMessage}", TAG) + } + if (continuation.isActive) continuation.resume(ok) + } + + override fun onBillingServiceDisconnected() { + OpenIapLog.i("Horizon service disconnected", TAG) + } + }) + } + } + } + + override val endConnection: MutationEndConnectionHandler = { + withContext(Dispatchers.IO) { + runCatching { + billingClient?.endConnection() + billingClient = null + true + }.getOrElse { false } + } + } + + override val fetchProducts: QueryFetchProductsHandler = { params -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (params.skus.isEmpty()) throw OpenIapError.EmptySkuList + + val queryType = params.type ?: ProductQueryType.All + val includeInApp = queryType == ProductQueryType.InApp || queryType == ProductQueryType.All + val includeSubs = queryType == ProductQueryType.Subs || queryType == ProductQueryType.All + + val inAppProducts = if (includeInApp) { + queryProductDetails(client, params.skus, BillingClient.ProductType.INAPP) + .map { it.toInAppProduct() } + } else emptyList() + + val subscriptionProducts = if (includeSubs) { + queryProductDetails(client, params.skus, BillingClient.ProductType.SUBS) + .map { it.toSubscriptionProduct() } + } else emptyList() + + when (queryType) { + ProductQueryType.InApp -> FetchProductsResultProducts(inAppProducts) + ProductQueryType.Subs -> FetchProductsResultSubscriptions(subscriptionProducts) + ProductQueryType.All -> { + val combined = buildList { + addAll(inAppProducts) + addAll(subscriptionProducts.map(ProductSubscriptionAndroid::toProduct)) + } + FetchProductsResultProducts(combined) + } + } + } + } + + override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + val purchases = queryPurchases(client, BillingClient.ProductType.INAPP) + + queryPurchases(client, BillingClient.ProductType.SUBS) + purchases + } + } + + override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + val subs = queryPurchases(client, BillingClient.ProductType.SUBS) + val filtered = if (subscriptionIds.isNullOrEmpty()) { + subs + } else { + subs.filter { purchase -> + val id = (purchase as? PurchaseAndroid)?.productId + subscriptionIds.contains(id) + } + } + filtered.mapNotNull { (it as? PurchaseAndroid)?.toActiveSubscription() } + } + } + + override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds -> + getActiveSubscriptions(subscriptionIds).isNotEmpty() + } + + override val requestPurchase: MutationRequestPurchaseHandler = { props -> + val purchases = withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + val androidArgs = props.toAndroidPurchaseArgs() + if (androidArgs.skus.isEmpty()) throw OpenIapError.EmptySkuList + + val activity = currentActivityRef?.get() ?: (context as? Activity) + if (activity == null) { + val err = OpenIapError.MissingCurrentActivity + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + val desiredType = if (androidArgs.type == ProductQueryType.Subs) { + BillingClient.ProductType.SUBS + } else BillingClient.ProductType.INAPP + + val details = queryProductDetails(client, androidArgs.skus, desiredType) + if (details.isEmpty()) { + val err = OpenIapError.SkuNotFound(androidArgs.skus.firstOrNull().orEmpty()) + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + val detailsBySku = details.associateBy { it.productId } + val orderedDetails = androidArgs.skus.mapNotNull { detailsBySku[it] } + if (orderedDetails.size != androidArgs.skus.size) { + val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) } + val err = OpenIapError.SkuNotFound(missingSku ?: "") + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + suspendCancellableCoroutine> { continuation -> + currentPurchaseCallback = { result -> + if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) + } + + val paramsList = orderedDetails.mapIndexed { index, detail -> + val builder = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(detail) + if (desiredType == BillingClient.ProductType.SUBS) { + val fromOffers = androidArgs.subscriptionOffers + ?.firstOrNull { it.sku == detail.productId } + ?.offerToken + val resolvedToken = fromOffers + ?: detail.subscriptionOfferDetails?.firstOrNull()?.offerToken + resolvedToken?.let { builder.setOfferToken(it) } + } + builder.build() + } + + val flowBuilder = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(paramsList) + .setIsOfferPersonalized(androidArgs.isOfferPersonalized == true) + + androidArgs.obfuscatedAccountId?.let { flowBuilder.setObfuscatedAccountId(it) } + androidArgs.obfuscatedProfileId?.let { flowBuilder.setObfuscatedProfileId(it) } + + val result = client.launchBillingFlow(activity, flowBuilder.build()) + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + val error = OpenIapError.fromBillingResponseCode(result.responseCode, result.debugMessage) + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(error) } } + if (continuation.isActive) continuation.resume(emptyList()) + currentPurchaseCallback = null + } + } + } + + RequestPurchaseResultPurchases(purchases) + } + + override val finishTransaction: MutationFinishTransactionHandler = { purchase, isConsumable -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + val token = purchase.purchaseToken ?: return@withContext + if (isConsumable == true) { + val params = ConsumeParams.newBuilder().setPurchaseToken(token).build() + suspendCancellableCoroutine { continuation -> + client.consumeAsync(params) { result, _ -> + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + OpenIapLog.w("Failed to consume Horizon purchase: ${result.debugMessage}", TAG) + } + if (continuation.isActive) continuation.resume(Unit) + } + } + } else { + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build() + suspendCancellableCoroutine { continuation -> + client.acknowledgePurchase(params) { result -> + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + OpenIapLog.w("Failed to acknowledge Horizon purchase: ${result.debugMessage}", TAG) + } + if (continuation.isActive) continuation.resume(Unit) + } + } + } + } + } + + override val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { purchaseToken -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build() + suspendCancellableCoroutine { continuation -> + client.acknowledgePurchase(params) { result -> + val success = result.responseCode == BillingClient.BillingResponseCode.OK + if (!success) { + OpenIapLog.w("Horizon acknowledge failed: ${result.debugMessage}", TAG) + } + if (continuation.isActive) continuation.resume(success) + } + } + } + } + + override val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { purchaseToken -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + val params = ConsumeParams.newBuilder().setPurchaseToken(purchaseToken).build() + suspendCancellableCoroutine { continuation -> + client.consumeAsync(params) { result, _ -> + val success = result.responseCode == BillingClient.BillingResponseCode.OK + if (!success) { + OpenIapLog.w("Horizon consume failed: ${result.debugMessage}", TAG) + } + if (continuation.isActive) continuation.resume(success) + } + } + } + } + + override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { _ -> } + + override val restorePurchases: MutationRestorePurchasesHandler = { + withContext(Dispatchers.IO) { + runCatching { getAvailablePurchases(null) } + Unit + } + } + + override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported } + + private val purchaseError: SubscriptionPurchaseErrorHandler = { + onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener) + } + + private val purchaseUpdated: SubscriptionPurchaseUpdatedHandler = { + onPurchaseUpdated(this::addPurchaseUpdateListener, this::removePurchaseUpdateListener) + } + + override val queryHandlers: QueryHandlers = QueryHandlers( + fetchProducts = fetchProducts, + getActiveSubscriptions = getActiveSubscriptions, + getAvailablePurchases = getAvailablePurchases, + getStorefrontIOS = { getStorefront() }, + hasActiveSubscriptions = hasActiveSubscriptions + ) + + override val mutationHandlers: MutationHandlers = MutationHandlers( + acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, + consumePurchaseAndroid = consumePurchaseAndroid, + deepLinkToSubscriptions = deepLinkToSubscriptions, + endConnection = endConnection, + finishTransaction = finishTransaction, + initConnection = initConnection, + requestPurchase = requestPurchase, + restorePurchases = restorePurchases, + validateReceipt = validateReceipt + ) + + override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( + purchaseError = purchaseError, + purchaseUpdated = purchaseUpdated + ) + + private suspend fun getStorefront(): String = withContext(Dispatchers.IO) { + val client = billingClient ?: return@withContext "" + suspendCancellableCoroutine { continuation -> + runCatching { + client.getBillingConfigAsync( + GetBillingConfigParams.newBuilder().build() + ) { result, config -> + if (continuation.isActive) { + val code = if (result.responseCode == BillingClient.BillingResponseCode.OK) { + config?.countryCode.orEmpty() + } else "" + continuation.resume(code) + } + } + }.onFailure { error -> + OpenIapLog.w("Horizon getStorefront failed: ${error.message}", TAG) + if (continuation.isActive) continuation.resume("") + } + } + } + + override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + purchaseUpdateListeners.add(listener) + } + + override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + purchaseUpdateListeners.remove(listener) + } + + override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + purchaseErrorListeners.add(listener) + } + + override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + purchaseErrorListeners.remove(listener) + } + + override fun onPurchasesUpdated(result: BillingResult, purchases: List?) { + Log.d(TAG, "onPurchasesUpdated code=${result.responseCode} count=${purchases?.size ?: 0}") + purchases?.forEachIndexed { index, purchase -> + Log.d( + TAG, + "[HorizonPurchase $index] token=${purchase.purchaseToken} orderId=${purchase.orderId} autoRenew=${purchase.isAutoRenewing()}" + ) + } + + if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + val mapped = purchases.map { purchase -> + val type = if (purchase.products?.any { it.contains("subs", ignoreCase = true) } == true) { + BillingClient.ProductType.SUBS + } else BillingClient.ProductType.INAPP + purchase.toPurchase(type) + } + mapped.forEach { converted -> + purchaseUpdateListeners.forEach { listener -> + runCatching { listener.onPurchaseUpdated(converted) } + } + } + currentPurchaseCallback?.invoke(Result.success(mapped)) + } else { + val error = OpenIapError.fromBillingResponseCode(result.responseCode, result.debugMessage) + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(error) } } + currentPurchaseCallback?.invoke(Result.success(emptyList())) + } + currentPurchaseCallback = null + } + + private suspend fun queryProductDetails( + client: BillingClient, + skus: List, + productType: String + ): List = suspendCancellableCoroutine { continuation -> + val products = skus.map { sku -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(sku) + .setProductType(productType) + .build() + } + val params = QueryProductDetailsParams.newBuilder().setProductList(products).build() + client.queryProductDetailsAsync(params) { result, details -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + if (continuation.isActive) continuation.resume(details ?: emptyList()) + } else { + OpenIapLog.w("Horizon queryProductDetails failed: ${result.debugMessage}", TAG) + if (continuation.isActive) continuation.resume(emptyList()) + } + } + } + + private suspend fun queryPurchases( + client: BillingClient, + productType: String + ): List = suspendCancellableCoroutine { continuation -> + val params = QueryPurchasesParams.newBuilder().setProductType(productType).build() + client.queryPurchasesAsync(params) { result, list -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + val mapped = (list ?: emptyList()).map { it.toPurchase(productType) } + if (continuation.isActive) continuation.resume(mapped) + } else { + if (continuation.isActive) continuation.resume(emptyList()) + } + } + } + + private fun buildBillingClient() { + val pendingPurchasesParams = com.meta.horizon.billingclient.api.PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + + val builder = BillingClient + .newBuilder(context) + .setListener(this) + .enablePendingPurchases(pendingPurchasesParams) + if (!appId.isNullOrEmpty()) { + builder.setAppId(appId) + } + billingClient = builder.build() + } + + // Alternative Billing (Google Play only - not supported on Horizon) + override suspend fun checkAlternativeBillingAvailability(): Boolean { + throw OpenIapError.FeatureNotSupported + } + + override suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean { + throw OpenIapError.FeatureNotSupported + } + + override suspend fun createAlternativeBillingReportingToken(): String? { + throw OpenIapError.FeatureNotSupported + } + + override fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { + // Not supported on Horizon + } + + override fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + // Not supported on Horizon + } + + override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + // Not supported on Horizon + } +} diff --git a/openiap/src/horizon/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt b/openiap/src/horizon/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt new file mode 100644 index 0000000..4f3cf39 --- /dev/null +++ b/openiap/src/horizon/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt @@ -0,0 +1,133 @@ +package dev.hyo.openiap.utils + +import com.meta.horizon.billingclient.api.ProductDetails as HorizonProductDetails +import com.meta.horizon.billingclient.api.Purchase as HorizonPurchase +import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.IapPlatform +import dev.hyo.openiap.PricingPhaseAndroid +import dev.hyo.openiap.PricingPhasesAndroid +import dev.hyo.openiap.ProductAndroid +import dev.hyo.openiap.ProductAndroidOneTimePurchaseOfferDetail +import dev.hyo.openiap.ProductSubscriptionAndroid +import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails +import dev.hyo.openiap.ProductType +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.PurchaseState + +internal object HorizonBillingConverters { + + fun HorizonProductDetails.toInAppProduct(): ProductAndroid { + val offer = oneTimePurchaseOfferDetails + val displayPrice = offer?.formattedPrice.orEmpty() + val currency = offer?.priceCurrencyCode.orEmpty() + val priceAmountMicros = offer?.priceAmountMicros ?: 0L + + return ProductAndroid( + currency = currency, + debugDescription = description, + description = description, + displayName = name, + displayPrice = displayPrice, + id = productId, + nameAndroid = name, + oneTimePurchaseOfferDetailsAndroid = offer?.let { + ProductAndroidOneTimePurchaseOfferDetail( + formattedPrice = it.formattedPrice, + priceAmountMicros = it.priceAmountMicros.toString(), + priceCurrencyCode = it.priceCurrencyCode + ) + }, + platform = IapPlatform.Android, + price = priceAmountMicros.toDouble() / 1_000_000.0, + subscriptionOfferDetailsAndroid = null, + title = title, + type = ProductType.InApp + ) + } + + fun HorizonProductDetails.toSubscriptionProduct(): ProductSubscriptionAndroid { + val offers = subscriptionOfferDetails.orEmpty() + val firstPhase = offers.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull() + val displayPrice = firstPhase?.formattedPrice.orEmpty() + val currency = firstPhase?.priceCurrencyCode.orEmpty() + + val pricingDetails = offers.map { offer -> + ProductSubscriptionAndroidOfferDetails( + basePlanId = offer.basePlanId, + offerId = offer.offerId, + offerTags = offer.offerTags, + offerToken = offer.offerToken, + pricingPhases = PricingPhasesAndroid( + pricingPhaseList = offer.pricingPhases.pricingPhaseList.map { phase -> + PricingPhaseAndroid( + billingCycleCount = phase.billingCycleCount, + billingPeriod = phase.billingPeriod, + formattedPrice = phase.formattedPrice, + priceAmountMicros = phase.priceAmountMicros.toString(), + priceCurrencyCode = phase.priceCurrencyCode, + recurrenceMode = phase.recurrenceMode, + ) + } + ) + ) + } + + return ProductSubscriptionAndroid( + currency = currency, + debugDescription = description, + description = description, + displayName = name, + displayPrice = displayPrice, + id = productId, + nameAndroid = name, + oneTimePurchaseOfferDetailsAndroid = oneTimePurchaseOfferDetails?.let { + ProductAndroidOneTimePurchaseOfferDetail( + formattedPrice = it.formattedPrice, + priceAmountMicros = it.priceAmountMicros.toString(), + priceCurrencyCode = it.priceCurrencyCode + ) + }, + platform = IapPlatform.Android, + price = firstPhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0), + subscriptionOfferDetailsAndroid = pricingDetails, + title = title, + type = ProductType.Subs + ) + } + + fun HorizonPurchase.toPurchase(productType: String): PurchaseAndroid { + val token = purchaseToken + val productsList = products ?: emptyList() + val purchaseState = PurchaseState.Purchased + + return PurchaseAndroid( + autoRenewingAndroid = isAutoRenewing(), + dataAndroid = originalJson, + developerPayloadAndroid = developerPayload, + id = orderId ?: token, + ids = productsList, + isAcknowledgedAndroid = isAcknowledged(), + isAutoRenewing = isAutoRenewing(), + obfuscatedAccountIdAndroid = null, + obfuscatedProfileIdAndroid = null, + packageNameAndroid = packageName, + platform = IapPlatform.Android, + productId = productsList.firstOrNull().orEmpty(), + purchaseState = purchaseState, + purchaseToken = token, + quantity = quantity ?: 1, + signatureAndroid = signature, + transactionDate = (purchaseTime ?: 0L).toDouble(), + transactionId = orderId ?: token + ) + } + + fun HorizonPurchase.toActiveSubscription(): ActiveSubscription = ActiveSubscription( + autoRenewingAndroid = isAutoRenewing(), + isActive = true, + productId = products?.firstOrNull().orEmpty(), + purchaseToken = purchaseToken, + transactionDate = (purchaseTime ?: 0L).toDouble(), + transactionId = orderId ?: purchaseToken + ) +} diff --git a/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt b/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt index 39437cd..d315530 100644 --- a/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt +++ b/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt @@ -89,7 +89,7 @@ class OpenIapModule( private val context: Context, private var alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE, private var userChoiceBillingListener: dev.hyo.openiap.listener.UserChoiceBillingListener? = null -) : PurchasesUpdatedListener { +) : OpenIapProtocol, PurchasesUpdatedListener { companion object { private const val TAG = "OpenIapModule" @@ -113,7 +113,7 @@ class OpenIapModule( private val userChoiceBillingListeners = mutableSetOf() private var currentPurchaseCallback: ((Result>) -> Unit)? = null - val initConnection: MutationInitConnectionHandler = { config -> + override val initConnection: MutationInitConnectionHandler = { config -> // Update alternativeBillingMode if provided in config config?.alternativeBillingModeAndroid?.let { modeAndroid -> OpenIapLog.d("Setting alternative billing mode from config: $modeAndroid", TAG) @@ -125,6 +125,7 @@ class OpenIapModule( } } + withContext(Dispatchers.IO) { suspendCancellableCoroutine { continuation -> initBillingClient( @@ -138,7 +139,7 @@ class OpenIapModule( } } - val endConnection: MutationEndConnectionHandler = { + override val endConnection: MutationEndConnectionHandler = { withContext(Dispatchers.IO) { runCatching { billingClient?.endConnection() @@ -148,7 +149,7 @@ class OpenIapModule( } } - val fetchProducts: QueryFetchProductsHandler = { params -> + override val fetchProducts: QueryFetchProductsHandler = { params -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -209,11 +210,11 @@ class OpenIapModule( } } } - val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ -> + override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ -> withContext(Dispatchers.IO) { restorePurchasesHelper(billingClient) } } - val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds -> + override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds -> withContext(Dispatchers.IO) { val androidPurchases = queryPurchases(billingClient, BillingClient.ProductType.SUBS) .filterIsInstance() @@ -227,7 +228,7 @@ class OpenIapModule( } } - val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds -> + override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds -> getActiveSubscriptions(subscriptionIds).isNotEmpty() } @@ -235,7 +236,7 @@ class OpenIapModule( * Check if alternative billing is available for this user/device * Step 1 of alternative billing flow */ - suspend fun checkAlternativeBillingAvailability(): Boolean = withContext(Dispatchers.IO) { + override suspend fun checkAlternativeBillingAvailability(): Boolean = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -274,7 +275,7 @@ class OpenIapModule( * Step 2 of alternative billing flow * Must be called BEFORE processing payment */ - suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = withContext(Dispatchers.IO) { + override suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -322,7 +323,7 @@ class OpenIapModule( * Must be called AFTER successful payment in your payment system * Token must be reported to Google Play backend within 24 hours */ - suspend fun createAlternativeBillingReportingToken(): String? = withContext(Dispatchers.IO) { + override suspend fun createAlternativeBillingReportingToken(): String? = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -363,7 +364,7 @@ class OpenIapModule( } } - val requestPurchase: MutationRequestPurchaseHandler = { props -> + override val requestPurchase: MutationRequestPurchaseHandler = { props -> val purchases = withContext(Dispatchers.IO) { // ALTERNATIVE_ONLY mode: Show information dialog and create token if (alternativeBillingMode == AlternativeBillingMode.ALTERNATIVE_ONLY) { @@ -684,7 +685,7 @@ class OpenIapModule( queryPurchases(billingClient, billingType) } - val finishTransaction: MutationFinishTransactionHandler = { purchase, isConsumable -> + override val finishTransaction: MutationFinishTransactionHandler = { purchase, isConsumable -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -715,7 +716,7 @@ class OpenIapModule( } } - val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { purchaseToken -> + override val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { purchaseToken -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build() @@ -732,7 +733,7 @@ class OpenIapModule( } } - val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { purchaseToken -> + override val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { purchaseToken -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared val params = ConsumeParams.newBuilder().setPurchaseToken(purchaseToken).build() @@ -749,7 +750,7 @@ class OpenIapModule( } } - val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { options -> + override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { options -> val pkg = options?.packageNameAndroid ?: context.packageName val uri = if (!options?.skuAndroid.isNullOrBlank()) { Uri.parse("https://play.google.com/store/account/subscriptions?sku=${options!!.skuAndroid}&package=$pkg") @@ -760,14 +761,14 @@ class OpenIapModule( context.startActivity(intent) } - val restorePurchases: MutationRestorePurchasesHandler = { + override val restorePurchases: MutationRestorePurchasesHandler = { withContext(Dispatchers.IO) { restorePurchasesHelper(billingClient) Unit } } - val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported } + override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported } private val purchaseError: SubscriptionPurchaseErrorHandler = { onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener) @@ -777,7 +778,7 @@ class OpenIapModule( onPurchaseUpdated(this::addPurchaseUpdateListener, this::removePurchaseUpdateListener) } - val queryHandlers: QueryHandlers = QueryHandlers( + override val queryHandlers: QueryHandlers = QueryHandlers( fetchProducts = fetchProducts, getActiveSubscriptions = getActiveSubscriptions, getAvailablePurchases = getAvailablePurchases, @@ -785,7 +786,7 @@ class OpenIapModule( hasActiveSubscriptions = hasActiveSubscriptions ) - val mutationHandlers: MutationHandlers = MutationHandlers( + override val mutationHandlers: MutationHandlers = MutationHandlers( acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, consumePurchaseAndroid = consumePurchaseAndroid, deepLinkToSubscriptions = deepLinkToSubscriptions, @@ -797,7 +798,7 @@ class OpenIapModule( validateReceipt = validateReceipt ) - val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( + override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( purchaseError = purchaseError, purchaseUpdated = purchaseUpdated ) @@ -826,27 +827,27 @@ class OpenIapModule( } } - fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { purchaseUpdateListeners.add(listener) } - fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { purchaseUpdateListeners.remove(listener) } - fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { purchaseErrorListeners.add(listener) } - fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { purchaseErrorListeners.remove(listener) } - fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + override fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { userChoiceBillingListeners.add(listener) } - fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { userChoiceBillingListeners.remove(listener) } @@ -1045,7 +1046,7 @@ class OpenIapModule( }) } - fun setActivity(activity: Activity?) { + override fun setActivity(activity: Activity?) { currentActivityRef = activity?.let { WeakReference(it) } } @@ -1054,7 +1055,7 @@ class OpenIapModule( * * @param listener User choice billing listener */ - fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { + override fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { userChoiceBillingListener = listener } } diff --git a/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt b/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt new file mode 100644 index 0000000..f31faef --- /dev/null +++ b/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt @@ -0,0 +1,47 @@ +package dev.hyo.openiap + +import android.app.Activity +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener + +/** + * Shared contract implemented by platform-specific OpenIAP billing modules. + * Provides access to generated handler typealiases so the store can remain provider-agnostic. + */ +interface OpenIapProtocol { + val initConnection: MutationInitConnectionHandler + val endConnection: MutationEndConnectionHandler + + val fetchProducts: QueryFetchProductsHandler + val getAvailablePurchases: QueryGetAvailablePurchasesHandler + val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler + val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler + + val requestPurchase: MutationRequestPurchaseHandler + val finishTransaction: MutationFinishTransactionHandler + val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler + val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler + val restorePurchases: MutationRestorePurchasesHandler + val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler + val validateReceipt: MutationValidateReceiptHandler + + val queryHandlers: QueryHandlers + val mutationHandlers: MutationHandlers + val subscriptionHandlers: SubscriptionHandlers + + fun setActivity(activity: Activity?) + + fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) + fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) + fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) + fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) + + // Alternative Billing (Google Play only) + suspend fun checkAlternativeBillingAvailability(): Boolean + suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean + suspend fun createAlternativeBillingReportingToken(): String? + fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) + fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) + fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) +} diff --git a/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index c62ec3f..d8d948e 100644 --- a/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -33,14 +33,14 @@ import dev.hyo.openiap.MutationInitConnectionHandler import dev.hyo.openiap.MutationEndConnectionHandler import android.app.Activity import android.content.Context -import com.android.billingclient.api.BillingClient import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.horizon.OpenIapHorizonModule import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.utils.toProduct -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import io.github.hyochan.openiap.BuildConfig import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -48,14 +48,16 @@ import kotlinx.coroutines.launch /** * OpenIapStore (Android) - * Convenience store that wraps OpenIapModule and provides spec-aligned, suspend APIs - * with observable StateFlows for UI layers (Compose/XML) to consume. - * - * @param module OpenIapModule instance + * Convenience store that wraps an [OpenIapProtocol] implementation (Play Store or Horizon) + * and exposes suspend APIs with observable StateFlows for UI layers to consume. */ -class OpenIapStore(private val module: OpenIapModule) { +class OpenIapStore(private val module: OpenIapProtocol) { + constructor(context: Context) : this(buildModule(context, null, null)) + 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 + * Convenience constructor that creates OpenIapModule with alternative billing support * * @param context Android context * @param alternativeBillingMode Alternative billing mode (default: NONE) @@ -65,19 +67,7 @@ class OpenIapStore(private val module: OpenIapModule) { context: Context, alternativeBillingMode: dev.hyo.openiap.AlternativeBillingMode = dev.hyo.openiap.AlternativeBillingMode.NONE, userChoiceBillingListener: dev.hyo.openiap.listener.UserChoiceBillingListener? = null - ) : this(OpenIapModule(context, alternativeBillingMode, userChoiceBillingListener)) - - /** - * 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)) + ) : this(OpenIapModule(context, alternativeBillingMode, userChoiceBillingListener) as OpenIapProtocol) // Public state private val _isConnected = MutableStateFlow(false) @@ -493,3 +483,29 @@ sealed class IapOperationResult { data class Failure(val message: String) : IapOperationResult() object Cancelled : IapOperationResult() } + +private fun buildModule(context: Context, store: String?, appId: String?): OpenIapProtocol { + val selected = (store ?: BuildConfig.OPENIAP_STORE).lowercase() + val resolvedAppId = appId ?: BuildConfig.HORIZON_APP_ID + return when (selected) { + "horizon", "meta", "quest" -> OpenIapHorizonModule(context, resolvedAppId) + "auto" -> if (isHorizonEnvironment(context)) { + OpenIapHorizonModule(context, resolvedAppId) + } else { + OpenIapModule(context) + } + "play", "google", "gplay", "googleplay", "gms" -> OpenIapModule(context) + else -> OpenIapModule(context) + } +} + +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 + } +} diff --git a/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt b/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt new file mode 100644 index 0000000..f7053cc --- /dev/null +++ b/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt @@ -0,0 +1,131 @@ +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) + } +}