diff --git a/Example/build.gradle.kts b/Example/build.gradle.kts index 7acf064..172d6e1 100644 --- a/Example/build.gradle.kts +++ b/Example/build.gradle.kts @@ -17,6 +17,14 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true + + val store = (project.findProperty("EXAMPLE_OPENIAP_STORE") as String?) ?: "play" + buildConfigField("String", "OPENIAP_STORE", "\"${store}\"") + + val appId = (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?) + ?: (project.findProperty("EXAMPLE_OPENIAP_APP_ID") as String?) + ?: "" + buildConfigField("String", "HORIZON_APP_ID", "\"${appId}\"") } buildTypes { @@ -44,6 +52,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 7d85bdd..415ac73 100644 --- a/Example/src/main/java/dev/hyo/martie/Constants.kt +++ b/Example/src/main/java/dev/hyo/martie/Constants.kt @@ -1,14 +1,28 @@ package dev.hyo.martie object IapConstants { - // App-defined SKU lists - val INAPP_SKUS = listOf( + private fun isHorizon(): Boolean = + dev.hyo.martie.BuildConfig.OPENIAP_STORE.equals("horizon", ignoreCase = true) + + private val HORIZON_INAPP = listOf( "dev.hyo.martie.10bulbs", - "dev.hyo.martie.30bulbs" + "dev.hyo.martie.30bulbs", + ) + private val HORIZON_SUBS = listOf( + "dev.hyo.martie.premium", ) - val SUBS_SKUS = listOf( - "dev.hyo.martie.premium" + private val PLAY_INAPP = listOf( + "dev.hyo.martie.10bulbs", + "dev.hyo.martie.30bulbs", ) -} + private val PLAY_SUBS = listOf( + "dev.hyo.martie.premium", + ) + + val INAPP_SKUS: List + get() = if (isHorizon()) HORIZON_INAPP else PLAY_INAPP + val SUBS_SKUS: List + get() = if (isHorizon()) HORIZON_SUBS else PLAY_SUBS +} 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 0de5a04..62f813d 100644 --- a/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt +++ b/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt @@ -47,7 +47,12 @@ fun PurchaseFlowScreen( val activity = context as? Activity val uiScope = rememberCoroutineScope() val appContext = context.applicationContext as Context - 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 267b29e..da04cf2 100644 --- a/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt +++ b/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt @@ -59,7 +59,12 @@ fun SubscriptionFlowScreen( val activity = context as? Activity val uiScope = rememberCoroutineScope() val appContext = context.applicationContext as Context - 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/README.md b/README.md index 52dc411..6aa11be 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 diff --git a/openiap/build.gradle.kts b/openiap/build.gradle.kts index c58fb4c..3e0faa0 100644 --- a/openiap/build.gradle.kts +++ b/openiap/build.gradle.kts @@ -20,6 +20,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") + buildConfigField("String", "OPENIAP_STORE", "\"play\"") + buildConfigField("String", "HORIZON_APP_ID", "\"\"") } buildTypes { @@ -44,6 +46,7 @@ android { // Enable Compose for composables in this library (IapContext) buildFeatures { compose = true + buildConfig = true } } @@ -53,6 +56,10 @@ dependencies { // Google Play Billing Library (align with app/lib v8) api("com.android.billingclient:billing-ktx:8.0.0") + + // Meta Horizon Billing Compatibility SDK (optional provider) + implementation("com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") + implementation("com.meta.horizon.platform.ovr:android-platform-sdk:72") // Kotlin Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") diff --git a/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt b/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt index 5e8aca5..2f094fc 100644 --- a/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt +++ b/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt @@ -68,7 +68,7 @@ import java.lang.ref.WeakReference /** * Main OpenIapModule implementation for Android */ -class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { +class OpenIapModule(private val context: Context) : OpenIapProtocol, PurchasesUpdatedListener { companion object { private const val TAG = "OpenIapModule" @@ -83,7 +83,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { private val purchaseErrorListeners = mutableSetOf() private var currentPurchaseCallback: ((Result>) -> Unit)? = null - val initConnection: MutationInitConnectionHandler = { + override val initConnection: MutationInitConnectionHandler = { withContext(Dispatchers.IO) { suspendCancellableCoroutine { continuation -> initBillingClient( @@ -97,7 +97,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { } } - val endConnection: MutationEndConnectionHandler = { + override val endConnection: MutationEndConnectionHandler = { withContext(Dispatchers.IO) { runCatching { billingClient?.endConnection() @@ -107,7 +107,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { } } - val fetchProducts: QueryFetchProductsHandler = { params -> + override val fetchProducts: QueryFetchProductsHandler = { params -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -140,11 +140,11 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { } } } - 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 purchases = queryPurchases(billingClient, BillingClient.ProductType.SUBS) val filtered = if (subscriptionIds.isNullOrEmpty()) { @@ -158,11 +158,11 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { } } - val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds -> + override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds -> getActiveSubscriptions(subscriptionIds).isNotEmpty() } - val requestPurchase: MutationRequestPurchaseHandler = { props -> + override val requestPurchase: MutationRequestPurchaseHandler = { props -> val purchases = withContext(Dispatchers.IO) { val androidArgs = props.toAndroidPurchaseArgs() val activity = currentActivityRef?.get() ?: (context as? Activity) @@ -313,7 +313,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { 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 @@ -344,7 +344,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { } } - 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() @@ -361,7 +361,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { } } - 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() @@ -378,7 +378,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { } } - 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") @@ -389,14 +389,14 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { 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) @@ -406,7 +406,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { onPurchaseUpdated(this::addPurchaseUpdateListener, this::removePurchaseUpdateListener) } - val queryHandlers: QueryHandlers = QueryHandlers( + override val queryHandlers: QueryHandlers = QueryHandlers( fetchProducts = fetchProducts, getActiveSubscriptions = getActiveSubscriptions, getAvailablePurchases = getAvailablePurchases, @@ -414,7 +414,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { hasActiveSubscriptions = hasActiveSubscriptions ) - val mutationHandlers: MutationHandlers = MutationHandlers( + override val mutationHandlers: MutationHandlers = MutationHandlers( acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, consumePurchaseAndroid = consumePurchaseAndroid, deepLinkToSubscriptions = deepLinkToSubscriptions, @@ -426,7 +426,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { validateReceipt = validateReceipt ) - val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( + override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( purchaseError = purchaseError, purchaseUpdated = purchaseUpdated ) @@ -455,19 +455,19 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { } } - 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) } @@ -557,7 +557,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { }) } - fun setActivity(activity: Activity?) { + override fun setActivity(activity: Activity?) { currentActivityRef = activity?.let { WeakReference(it) } } } 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..5ed75fd --- /dev/null +++ b/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt @@ -0,0 +1,38 @@ +package dev.hyo.openiap + +import android.app.Activity +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener + +/** + * 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) +} diff --git a/openiap/src/main/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt b/openiap/src/main/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt new file mode 100644 index 0000000..ba08187 --- /dev/null +++ b/openiap/src/main/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt @@ -0,0 +1,481 @@ +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.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.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 builder = BillingClient + .newBuilder(context) + .setListener(this) + .enablePendingPurchases() + if (!appId.isNullOrEmpty()) { + builder.setAppId(appId) + } + billingClient = builder.build() + } +} 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 85113fa..babe1cf 100644 --- a/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -24,23 +24,27 @@ import dev.hyo.openiap.RequestPurchaseResultPurchase import dev.hyo.openiap.RequestPurchaseResultPurchases 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 io.github.hyochan.openiap.BuildConfig import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** * OpenIapStore (Android) - * Convenience store that wraps OpenIapModule and provides spec-aligned, suspend APIs - * with observable StateFlows for UI layers (Compose/XML) to consume. + * 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) { - constructor(context: Context) : this(OpenIapModule(context)) +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)) // Public state private val _isConnected = MutableStateFlow(false) @@ -111,7 +115,7 @@ class OpenIapStore(private val module: OpenIapModule) { // Expose a way to set the current Activity for purchase flows fun setActivity(activity: Activity?) { - (module as? OpenIapModule)?.setActivity(activity) + module.setActivity(activity) } init { @@ -453,3 +457,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/main/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt b/openiap/src/main/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt new file mode 100644 index 0000000..4f3cf39 --- /dev/null +++ b/openiap/src/main/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 + ) +}