Skip to content
This repository was archived by the owner on Oct 17, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Example/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -44,6 +52,7 @@ android {

buildFeatures {
compose = true
buildConfig = true
}

packaging {
Expand Down
26 changes: 20 additions & 6 deletions Example/src/main/java/dev/hyo/martie/Constants.kt
Original file line number Diff line number Diff line change
@@ -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<String>
get() = if (isHorizon()) HORIZON_INAPP else PLAY_INAPP

val SUBS_SKUS: List<String>
get() = if (isHorizon()) HORIZON_SUBS else PLAY_SUBS
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProductAndroid>() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProductAndroid>() }
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions openiap/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -44,6 +46,7 @@ android {
// Enable Compose for composables in this library (IapContext)
buildFeatures {
compose = true
buildConfig = true
}
}

Expand All @@ -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")
Expand Down
44 changes: 22 additions & 22 deletions openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -83,7 +83,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
private var currentPurchaseCallback: ((Result<List<Purchase>>) -> Unit)? = null

val initConnection: MutationInitConnectionHandler = {
override val initConnection: MutationInitConnectionHandler = {
withContext(Dispatchers.IO) {
suspendCancellableCoroutine<Boolean> { continuation ->
initBillingClient(
Expand All @@ -97,7 +97,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
}
}

val endConnection: MutationEndConnectionHandler = {
override val endConnection: MutationEndConnectionHandler = {
withContext(Dispatchers.IO) {
runCatching {
billingClient?.endConnection()
Expand All @@ -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
Expand Down Expand Up @@ -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()) {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -406,15 +406,15 @@ 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,
getStorefrontIOS = { getStorefront() },
hasActiveSubscriptions = hasActiveSubscriptions
)

val mutationHandlers: MutationHandlers = MutationHandlers(
override val mutationHandlers: MutationHandlers = MutationHandlers(
acknowledgePurchaseAndroid = acknowledgePurchaseAndroid,
consumePurchaseAndroid = consumePurchaseAndroid,
deepLinkToSubscriptions = deepLinkToSubscriptions,
Expand All @@ -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
)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) }
}
}
38 changes: 38 additions & 0 deletions openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading