Skip to content
This repository was archived by the owner on Oct 17, 2025. It is now read-only.

Commit b659923

Browse files
committed
feat(openiap): add horizon provider
1 parent bb8ddbd commit b659923

File tree

11 files changed

+774
-36
lines changed

11 files changed

+774
-36
lines changed

Example/build.gradle.kts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ android {
1717

1818
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
1919
vectorDrawables.useSupportLibrary = true
20+
21+
val store = (project.findProperty("EXAMPLE_OPENIAP_STORE") as String?) ?: "play"
22+
buildConfigField("String", "OPENIAP_STORE", "\"${store}\"")
23+
24+
val appId = (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?)
25+
?: (project.findProperty("EXAMPLE_OPENIAP_APP_ID") as String?)
26+
?: ""
27+
buildConfigField("String", "HORIZON_APP_ID", "\"${appId}\"")
2028
}
2129

2230
buildTypes {
@@ -44,6 +52,7 @@ android {
4452

4553
buildFeatures {
4654
compose = true
55+
buildConfig = true
4756
}
4857

4958
packaging {
Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
package dev.hyo.martie
22

33
object IapConstants {
4-
// App-defined SKU lists
5-
val INAPP_SKUS = listOf(
4+
private fun isHorizon(): Boolean =
5+
dev.hyo.martie.BuildConfig.OPENIAP_STORE.equals("horizon", ignoreCase = true)
6+
7+
private val HORIZON_INAPP = listOf(
68
"dev.hyo.martie.10bulbs",
7-
"dev.hyo.martie.30bulbs"
9+
"dev.hyo.martie.30bulbs",
10+
)
11+
private val HORIZON_SUBS = listOf(
12+
"dev.hyo.martie.premium",
813
)
914

10-
val SUBS_SKUS = listOf(
11-
"dev.hyo.martie.premium"
15+
private val PLAY_INAPP = listOf(
16+
"dev.hyo.martie.10bulbs",
17+
"dev.hyo.martie.30bulbs",
1218
)
13-
}
19+
private val PLAY_SUBS = listOf(
20+
"dev.hyo.martie.premium",
21+
)
22+
23+
val INAPP_SKUS: List<String>
24+
get() = if (isHorizon()) HORIZON_INAPP else PLAY_INAPP
1425

26+
val SUBS_SKUS: List<String>
27+
get() = if (isHorizon()) HORIZON_SUBS else PLAY_SUBS
28+
}

Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ fun PurchaseFlowScreen(
4747
val activity = context as? Activity
4848
val uiScope = rememberCoroutineScope()
4949
val appContext = context.applicationContext as Context
50-
val iapStore = storeParam ?: remember(appContext) { OpenIapStore(appContext) }
50+
val iapStore = storeParam ?: remember(appContext) {
51+
val storeKey = dev.hyo.martie.BuildConfig.OPENIAP_STORE
52+
val appId = dev.hyo.martie.BuildConfig.HORIZON_APP_ID
53+
runCatching { OpenIapStore(appContext, storeKey, appId) }
54+
.getOrElse { OpenIapStore(appContext, "auto", appId) }
55+
}
5156
val products by iapStore.products.collectAsState()
5257
val purchases by iapStore.availablePurchases.collectAsState()
5358
val androidProducts = remember(products) { products.filterIsInstance<ProductAndroid>() }

Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ fun SubscriptionFlowScreen(
5959
val activity = context as? Activity
6060
val uiScope = rememberCoroutineScope()
6161
val appContext = context.applicationContext as Context
62-
val iapStore = storeParam ?: remember(appContext) { OpenIapStore(appContext) }
62+
val iapStore = storeParam ?: remember(appContext) {
63+
val storeKey = dev.hyo.martie.BuildConfig.OPENIAP_STORE
64+
val appId = dev.hyo.martie.BuildConfig.HORIZON_APP_ID
65+
runCatching { OpenIapStore(appContext, storeKey, appId) }
66+
.getOrElse { OpenIapStore(appContext, "auto", appId) }
67+
}
6368
val products by iapStore.products.collectAsState()
6469
val purchases by iapStore.availablePurchases.collectAsState()
6570
val androidProducts = remember(products) { products.filterIsInstance<ProductAndroid>() }

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ OpenIAP GMS is a modern, type-safe Kotlin library that simplifies Google Play in
2929
- 🔐 **Google Play Billing v8** - Latest billing library with enhanced security
3030
-**Kotlin Coroutines** - Modern async/await API
3131
- 🎯 **Type Safe** - Full Kotlin type safety with sealed classes
32+
- 🥽 **Meta Horizon OS Support** - Optional compatibility SDK integration alongside Play Billing
3233
- 🔄 **Real-time Events** - Purchase update and error listeners
3334
- 🧵 **Thread Safe** - Concurrent operations with proper synchronization
3435
- 📱 **Easy Integration** - Simple singleton pattern with context management
@@ -52,6 +53,21 @@ dependencies {
5253
}
5354
```
5455

56+
### Optional provider configuration
57+
58+
Set the target billing provider via `BuildConfig` fields (default is `play`). The library will also auto-detect Horizon hardware when `auto` is supplied.
59+
60+
```kotlin
61+
android {
62+
defaultConfig {
63+
buildConfigField("String", "OPENIAP_STORE", "\"auto\"") // play | horizon | auto
64+
buildConfigField("String", "HORIZON_APP_ID", "\"YOUR_APP_ID\"")
65+
}
66+
}
67+
```
68+
69+
The example app reads the same values via `EXAMPLE_OPENIAP_STORE` / `EXAMPLE_HORIZON_APP_ID` Gradle properties for quick testing.
70+
5571
Or `build.gradle`:
5672

5773
```groovy

openiap/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ android {
2020

2121
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2222
consumerProguardFiles("consumer-rules.pro")
23+
buildConfigField("String", "OPENIAP_STORE", "\"play\"")
24+
buildConfigField("String", "HORIZON_APP_ID", "\"\"")
2325
}
2426

2527
buildTypes {
@@ -44,6 +46,7 @@ android {
4446
// Enable Compose for composables in this library (IapContext)
4547
buildFeatures {
4648
compose = true
49+
buildConfig = true
4750
}
4851
}
4952

@@ -53,6 +56,10 @@ dependencies {
5356

5457
// Google Play Billing Library (align with app/lib v8)
5558
api("com.android.billingclient:billing-ktx:8.0.0")
59+
60+
// Meta Horizon Billing Compatibility SDK (optional provider)
61+
implementation("com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1")
62+
implementation("com.meta.horizon.platform.ovr:android-platform-sdk:72")
5663

5764
// Kotlin Coroutines
5865
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")

openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import java.lang.ref.WeakReference
6868
/**
6969
* Main OpenIapModule implementation for Android
7070
*/
71-
class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
71+
class OpenIapModule(private val context: Context) : OpenIapProtocol, PurchasesUpdatedListener {
7272

7373
companion object {
7474
private const val TAG = "OpenIapModule"
@@ -83,7 +83,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
8383
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
8484
private var currentPurchaseCallback: ((Result<List<Purchase>>) -> Unit)? = null
8585

86-
val initConnection: MutationInitConnectionHandler = {
86+
override val initConnection: MutationInitConnectionHandler = {
8787
withContext(Dispatchers.IO) {
8888
suspendCancellableCoroutine<Boolean> { continuation ->
8989
initBillingClient(
@@ -97,7 +97,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
9797
}
9898
}
9999

100-
val endConnection: MutationEndConnectionHandler = {
100+
override val endConnection: MutationEndConnectionHandler = {
101101
withContext(Dispatchers.IO) {
102102
runCatching {
103103
billingClient?.endConnection()
@@ -107,7 +107,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
107107
}
108108
}
109109

110-
val fetchProducts: QueryFetchProductsHandler = { params ->
110+
override val fetchProducts: QueryFetchProductsHandler = { params ->
111111
withContext(Dispatchers.IO) {
112112
val client = billingClient ?: throw OpenIapError.NotPrepared
113113
if (!client.isReady) throw OpenIapError.NotPrepared
@@ -140,11 +140,11 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
140140
}
141141
}
142142
}
143-
val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ ->
143+
override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ ->
144144
withContext(Dispatchers.IO) { restorePurchasesHelper(billingClient) }
145145
}
146146

147-
val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds ->
147+
override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds ->
148148
withContext(Dispatchers.IO) {
149149
val purchases = queryPurchases(billingClient, BillingClient.ProductType.SUBS)
150150
val filtered = if (subscriptionIds.isNullOrEmpty()) {
@@ -158,11 +158,11 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
158158
}
159159
}
160160

161-
val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds ->
161+
override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds ->
162162
getActiveSubscriptions(subscriptionIds).isNotEmpty()
163163
}
164164

165-
val requestPurchase: MutationRequestPurchaseHandler = { props ->
165+
override val requestPurchase: MutationRequestPurchaseHandler = { props ->
166166
val purchases = withContext(Dispatchers.IO) {
167167
val androidArgs = props.toAndroidPurchaseArgs()
168168
val activity = currentActivityRef?.get() ?: (context as? Activity)
@@ -313,7 +313,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
313313
queryPurchases(billingClient, billingType)
314314
}
315315

316-
val finishTransaction: MutationFinishTransactionHandler = { purchase, isConsumable ->
316+
override val finishTransaction: MutationFinishTransactionHandler = { purchase, isConsumable ->
317317
withContext(Dispatchers.IO) {
318318
val client = billingClient ?: throw OpenIapError.NotPrepared
319319
if (!client.isReady) throw OpenIapError.NotPrepared
@@ -344,7 +344,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
344344
}
345345
}
346346

347-
val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { purchaseToken ->
347+
override val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { purchaseToken ->
348348
withContext(Dispatchers.IO) {
349349
val client = billingClient ?: throw OpenIapError.NotPrepared
350350
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build()
@@ -361,7 +361,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
361361
}
362362
}
363363

364-
val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { purchaseToken ->
364+
override val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { purchaseToken ->
365365
withContext(Dispatchers.IO) {
366366
val client = billingClient ?: throw OpenIapError.NotPrepared
367367
val params = ConsumeParams.newBuilder().setPurchaseToken(purchaseToken).build()
@@ -378,7 +378,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
378378
}
379379
}
380380

381-
val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { options ->
381+
override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { options ->
382382
val pkg = options?.packageNameAndroid ?: context.packageName
383383
val uri = if (!options?.skuAndroid.isNullOrBlank()) {
384384
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 {
389389
context.startActivity(intent)
390390
}
391391

392-
val restorePurchases: MutationRestorePurchasesHandler = {
392+
override val restorePurchases: MutationRestorePurchasesHandler = {
393393
withContext(Dispatchers.IO) {
394394
restorePurchasesHelper(billingClient)
395395
Unit
396396
}
397397
}
398398

399-
val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported }
399+
override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported }
400400

401401
private val purchaseError: SubscriptionPurchaseErrorHandler = {
402402
onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener)
@@ -406,15 +406,15 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
406406
onPurchaseUpdated(this::addPurchaseUpdateListener, this::removePurchaseUpdateListener)
407407
}
408408

409-
val queryHandlers: QueryHandlers = QueryHandlers(
409+
override val queryHandlers: QueryHandlers = QueryHandlers(
410410
fetchProducts = fetchProducts,
411411
getActiveSubscriptions = getActiveSubscriptions,
412412
getAvailablePurchases = getAvailablePurchases,
413413
getStorefrontIOS = { getStorefront() },
414414
hasActiveSubscriptions = hasActiveSubscriptions
415415
)
416416

417-
val mutationHandlers: MutationHandlers = MutationHandlers(
417+
override val mutationHandlers: MutationHandlers = MutationHandlers(
418418
acknowledgePurchaseAndroid = acknowledgePurchaseAndroid,
419419
consumePurchaseAndroid = consumePurchaseAndroid,
420420
deepLinkToSubscriptions = deepLinkToSubscriptions,
@@ -426,7 +426,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
426426
validateReceipt = validateReceipt
427427
)
428428

429-
val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers(
429+
override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers(
430430
purchaseError = purchaseError,
431431
purchaseUpdated = purchaseUpdated
432432
)
@@ -455,19 +455,19 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
455455
}
456456
}
457457

458-
fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) {
458+
override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) {
459459
purchaseUpdateListeners.add(listener)
460460
}
461461

462-
fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) {
462+
override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) {
463463
purchaseUpdateListeners.remove(listener)
464464
}
465465

466-
fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) {
466+
override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) {
467467
purchaseErrorListeners.add(listener)
468468
}
469469

470-
fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) {
470+
override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) {
471471
purchaseErrorListeners.remove(listener)
472472
}
473473

@@ -557,7 +557,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
557557
})
558558
}
559559

560-
fun setActivity(activity: Activity?) {
560+
override fun setActivity(activity: Activity?) {
561561
currentActivityRef = activity?.let { WeakReference(it) }
562562
}
563563
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package dev.hyo.openiap
2+
3+
import android.app.Activity
4+
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
5+
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
6+
7+
/**
8+
* Shared contract implemented by platform-specific OpenIAP billing modules.
9+
* Provides access to generated handler typealiases so the store can remain provider-agnostic.
10+
*/
11+
interface OpenIapProtocol {
12+
val initConnection: MutationInitConnectionHandler
13+
val endConnection: MutationEndConnectionHandler
14+
15+
val fetchProducts: QueryFetchProductsHandler
16+
val getAvailablePurchases: QueryGetAvailablePurchasesHandler
17+
val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler
18+
val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler
19+
20+
val requestPurchase: MutationRequestPurchaseHandler
21+
val finishTransaction: MutationFinishTransactionHandler
22+
val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler
23+
val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler
24+
val restorePurchases: MutationRestorePurchasesHandler
25+
val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler
26+
val validateReceipt: MutationValidateReceiptHandler
27+
28+
val queryHandlers: QueryHandlers
29+
val mutationHandlers: MutationHandlers
30+
val subscriptionHandlers: SubscriptionHandlers
31+
32+
fun setActivity(activity: Activity?)
33+
34+
fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener)
35+
fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener)
36+
fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener)
37+
fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener)
38+
}

0 commit comments

Comments
 (0)