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

Commit e0f2bd6

Browse files
committed
feat(openiap): add horizon provider
1 parent e9ec3b4 commit e0f2bd6

File tree

12 files changed

+774
-36
lines changed

12 files changed

+774
-36
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,14 @@
3535
"**/node_modules/": true
3636
},
3737
"gradle.nestedProjects": true,
38-
"gradle.javaDebug": true,
3938
"typescript.validate.enable": false,
4039
"javascript.validate.enable": false,
4140
"typescript.tsc.autoDetect": "off",
4241
"npm.autoDetect": "off",
4342
"cSpell.words": [
4443
"billingclient",
4544
"gson",
45+
"martie",
4646
"openiap",
4747
"skus"
4848
]

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
@@ -46,7 +46,12 @@ fun PurchaseFlowScreen(
4646
val activity = remember(context) { context.findActivity() }
4747
val uiScope = rememberCoroutineScope()
4848
val appContext = remember(context) { context.applicationContext }
49-
val iapStore = storeParam ?: remember(appContext) { OpenIapStore(appContext) }
49+
val iapStore = storeParam ?: remember(appContext) {
50+
val storeKey = dev.hyo.martie.BuildConfig.OPENIAP_STORE
51+
val appId = dev.hyo.martie.BuildConfig.HORIZON_APP_ID
52+
runCatching { OpenIapStore(appContext, storeKey, appId) }
53+
.getOrElse { OpenIapStore(appContext, "auto", appId) }
54+
}
5055
val products by iapStore.products.collectAsState()
5156
val purchases by iapStore.availablePurchases.collectAsState()
5257
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 = remember(context) { context.findActivity() }
6060
val uiScope = rememberCoroutineScope()
6161
val appContext = remember(context) { context.applicationContext }
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"
@@ -84,7 +84,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
8484
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
8585
private var currentPurchaseCallback: ((Result<List<Purchase>>) -> Unit)? = null
8686

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

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

111-
val fetchProducts: QueryFetchProductsHandler = { params ->
111+
override val fetchProducts: QueryFetchProductsHandler = { params ->
112112
withContext(Dispatchers.IO) {
113113
val client = billingClient ?: throw OpenIapError.NotPrepared
114114
if (!client.isReady) throw OpenIapError.NotPrepared
@@ -141,11 +141,11 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
141141
}
142142
}
143143
}
144-
val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ ->
144+
override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ ->
145145
withContext(Dispatchers.IO) { restorePurchasesHelper(billingClient) }
146146
}
147147

148-
val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds ->
148+
override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds ->
149149
withContext(Dispatchers.IO) {
150150
val androidPurchases = queryPurchases(billingClient, BillingClient.ProductType.SUBS)
151151
.filterIsInstance<PurchaseAndroid>()
@@ -159,11 +159,11 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
159159
}
160160
}
161161

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

166-
val requestPurchase: MutationRequestPurchaseHandler = { props ->
166+
override val requestPurchase: MutationRequestPurchaseHandler = { props ->
167167
val purchases = withContext(Dispatchers.IO) {
168168
val androidArgs = props.toAndroidPurchaseArgs()
169169
val activity = currentActivityRef?.get() ?: fallbackActivity
@@ -314,7 +314,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
314314
queryPurchases(billingClient, billingType)
315315
}
316316

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

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

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

382-
val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { options ->
382+
override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { options ->
383383
val pkg = options?.packageNameAndroid ?: context.packageName
384384
val uri = if (!options?.skuAndroid.isNullOrBlank()) {
385385
Uri.parse("https://play.google.com/store/account/subscriptions?sku=${options!!.skuAndroid}&package=$pkg")
@@ -390,14 +390,14 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
390390
context.startActivity(intent)
391391
}
392392

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

400-
val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported }
400+
override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported }
401401

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

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

418-
val mutationHandlers: MutationHandlers = MutationHandlers(
418+
override val mutationHandlers: MutationHandlers = MutationHandlers(
419419
acknowledgePurchaseAndroid = acknowledgePurchaseAndroid,
420420
consumePurchaseAndroid = consumePurchaseAndroid,
421421
deepLinkToSubscriptions = deepLinkToSubscriptions,
@@ -427,7 +427,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
427427
validateReceipt = validateReceipt
428428
)
429429

430-
val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers(
430+
override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers(
431431
purchaseError = purchaseError,
432432
purchaseUpdated = purchaseUpdated
433433
)
@@ -456,19 +456,19 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
456456
}
457457
}
458458

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

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

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

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

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

561-
fun setActivity(activity: Activity?) {
561+
override fun setActivity(activity: Activity?) {
562562
currentActivityRef = activity?.let { WeakReference(it) }
563563
}
564564
}
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)