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

Commit 0c9e7cb

Browse files
committed
feat(horizon): add provider and inline selection
Add first-party Horizon OS provider, wire selection in OpenIapStore, and enable optional APP_ID injection. Replace logs with OpenIapLog and surface purchase errors in Example screens. Includes Horizon compat + platform SDK deps.
1 parent 1470076 commit 0c9e7cb

File tree

12 files changed

+594
-39
lines changed

12 files changed

+594
-39
lines changed

.vscode/launch.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@
1212
],
1313
"console": "integratedTerminal"
1414
},
15+
{
16+
"name": "🧱 Android: Assemble Example (Horizon/Auto)",
17+
"type": "node",
18+
"request": "launch",
19+
"runtimeExecutable": "bash",
20+
"runtimeArgs": [
21+
"-lc",
22+
"./gradlew :Example:assembleDebug -PEXAMPLE_OPENIAP_STORE=auto"
23+
],
24+
"console": "integratedTerminal"
25+
},
1526
{
1627
"name": "▶️ Android: Start Example Activity",
1728
"type": "node",
@@ -66,6 +77,25 @@
6677
"./gradlew :openiap:test --no-daemon"
6778
],
6879
"console": "integratedTerminal"
80+
},
81+
{
82+
"name": "🎯 Android: Run Example (Horizon Force)",
83+
"type": "node",
84+
"request": "launch",
85+
"runtimeExecutable": "bash",
86+
"runtimeArgs": [
87+
"-lc",
88+
"./gradlew :Example:installDebug -PEXAMPLE_OPENIAP_STORE=horizon -PEXAMPLE_HORIZON_APP_ID='${input:horizonAppId}' && adb shell am start -n dev.hyo.martie/.MainActivity"
89+
],
90+
"console": "integratedTerminal"
91+
}
92+
],
93+
"inputs": [
94+
{
95+
"id": "horizonAppId",
96+
"type": "promptString",
97+
"description": "Enter Horizon APP_ID",
98+
"default": ""
6999
}
70100
]
71101
}

CONTRIBUTING.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ cd openiap-google
3333
adb shell am start -n dev.hyo.martie/.MainActivity
3434
```
3535

36+
### Horizon Quickstart (Local)
37+
38+
The core library ships Play-only by default but supports Horizon if a provider is on the classpath.
39+
40+
- Provider selection is done via `OpenIapStore(context, store?, appId?)` or BuildConfig flags.
41+
- Run Example targeting Horizon/auto:
42+
- VS Code: use launch config "Android: Run Example (Horizon/Auto)"
43+
- CLI: `./gradlew :Example:installDebug -PEXAMPLE_OPENIAP_STORE=auto`
44+
- CLI (force): `./gradlew :Example:installDebug -PEXAMPLE_OPENIAP_STORE=horizon -PEXAMPLE_HORIZON_APP_ID=YOUR_APP_ID`
45+
- VS Code (force): use launch config "Android: Run Example (Horizon Force)" — it will prompt for `HORIZON_APP_ID` via input box and pass `-PEXAMPLE_HORIZON_APP_ID` to Gradle.
46+
- Notes:
47+
- Use a Quest device with Meta services; emulators are not supported.
48+
- If the provider is missing, the factory falls back to Play when using `auto`.
49+
3650
## Code Style
3751

3852
- Follow the official Kotlin Coding Conventions

Example/build.gradle.kts

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

1818
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
1919
vectorDrawables.useSupportLibrary = true
20+
21+
// Optional store override for example app: play | horizon | auto
22+
val store = (project.findProperty("EXAMPLE_OPENIAP_STORE") as String?) ?: "play"
23+
buildConfigField("String", "OPENIAP_STORE", "\"${store}\"")
24+
25+
// Optional Horizon app id (provider-specific)
26+
// Prefer EXAMPLE_HORIZON_APP_ID; fallback to legacy EXAMPLE_OPENIAP_APP_ID if provided
27+
val appId = (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?)
28+
?: (project.findProperty("EXAMPLE_OPENIAP_APP_ID") as String?)
29+
?: ""
30+
buildConfigField("String", "HORIZON_APP_ID", "\"${appId}\"")
2031
}
2132

2233
buildTypes {
@@ -44,6 +55,7 @@ android {
4455

4556
buildFeatures {
4657
compose = true
58+
buildConfig = true
4759
}
4860

4961
packaging {
Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
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+
// Define your Horizon product IDs here (placeholders)
8+
private val HORIZON_INAPP = listOf(
69
"dev.hyo.martie.10bulbs",
7-
"dev.hyo.martie.30bulbs"
10+
"dev.hyo.martie.30bulbs",
11+
)
12+
private val HORIZON_SUBS = listOf(
13+
"dev.hyo.martie.premium",
814
)
915

10-
val SUBS_SKUS = listOf(
11-
"dev.hyo.martie.premium"
16+
// Google Play product IDs (existing)
17+
private val PLAY_INAPP = listOf(
18+
"dev.hyo.martie.10bulbs",
19+
"dev.hyo.martie.30bulbs",
20+
)
21+
private val PLAY_SUBS = listOf(
22+
"dev.hyo.martie.premium",
1223
)
13-
}
1424

25+
fun inappSkus(): List<String> = if (isHorizon()) HORIZON_INAPP else PLAY_INAPP
26+
fun subsSkus(): List<String> = if (isHorizon()) HORIZON_SUBS else PLAY_SUBS
27+
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,15 @@ fun AvailablePurchasesScreen(
3333
storeParam: OpenIapStore? = null
3434
) {
3535
val context = LocalContext.current
36+
val appContext = context.applicationContext
3637
val iapStore = storeParam ?: (IapContext.LocalOpenIapStore.current
37-
?: IapContext.rememberOpenIapStore())
38+
?: remember(appContext) {
39+
val storeKey = dev.hyo.martie.BuildConfig.OPENIAP_STORE
40+
val appId = dev.hyo.martie.BuildConfig.HORIZON_APP_ID
41+
android.util.Log.i("OpenIapFactory", "example-create storeKey=${storeKey} appIdSet=${appId.isNotEmpty()}")
42+
runCatching { OpenIapStore(appContext, storeKey, appId) }
43+
.getOrElse { OpenIapStore(appContext, "auto", appId) }
44+
})
3845
val purchases by iapStore.availablePurchases.collectAsState()
3946
val status by iapStore.status.collectAsState()
4047
val connectionStatus by iapStore.connectionStatus.collectAsState()

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,16 @@ fun PurchaseFlowScreen(
4343
val activity = context as? Activity
4444
val uiScope = rememberCoroutineScope()
4545
val appContext = context.applicationContext as Context
46-
val iapStore = storeParam ?: remember(appContext) { OpenIapStore(appContext) }
46+
val iapStore = storeParam ?: remember(appContext) {
47+
val storeKey = dev.hyo.martie.BuildConfig.OPENIAP_STORE
48+
val appId = dev.hyo.martie.BuildConfig.HORIZON_APP_ID
49+
android.util.Log.i("OpenIapFactory", "example-create storeKey=${storeKey} appIdSet=${appId.isNotEmpty()}")
50+
runCatching { OpenIapStore(appContext, storeKey, appId) }
51+
.getOrElse {
52+
// Fallback to auto if horizon provider not present
53+
OpenIapStore(appContext, "auto", appId)
54+
}
55+
}
4756
val products by iapStore.products.collectAsState()
4857
val purchases by iapStore.availablePurchases.collectAsState()
4958
val status by iapStore.status.collectAsState()
@@ -64,7 +73,7 @@ fun PurchaseFlowScreen(
6473
if (connected) {
6574
iapStore.setActivity(activity)
6675
iapStore.fetchProducts(
67-
skus = IapConstants.INAPP_SKUS,
76+
skus = IapConstants.inappSkus(),
6877
type = ProductRequest.ProductRequestType.InApp
6978
)
7079
iapStore.getAvailablePurchases()
@@ -97,7 +106,7 @@ fun PurchaseFlowScreen(
97106
try {
98107
iapStore.setActivity(activity)
99108
iapStore.fetchProducts(
100-
skus = IapConstants.INAPP_SKUS,
109+
skus = IapConstants.inappSkus(),
101110
type = ProductRequest.ProductRequestType.InApp
102111
)
103112
} catch (_: Exception) { }
@@ -311,7 +320,7 @@ fun PurchaseFlowScreen(
311320
scope.launch {
312321
try {
313322
iapStore.fetchProducts(
314-
skus = IapConstants.INAPP_SKUS,
323+
skus = IapConstants.inappSkus(),
315324
type = ProductRequest.ProductRequestType.InApp
316325
)
317326
} catch (_: Exception) { }

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ fun SubscriptionFlowScreen(
5555
val activity = context as? Activity
5656
val uiScope = rememberCoroutineScope()
5757
val appContext = context.applicationContext as Context
58-
val iapStore = storeParam ?: remember(appContext) { OpenIapStore(appContext) }
58+
val iapStore = storeParam ?: remember(appContext) {
59+
val storeKey = dev.hyo.martie.BuildConfig.OPENIAP_STORE
60+
val appId = dev.hyo.martie.BuildConfig.HORIZON_APP_ID
61+
android.util.Log.i("OpenIapFactory", "example-create storeKey=${storeKey} appIdSet=${appId.isNotEmpty()}")
62+
runCatching { OpenIapStore(appContext, storeKey, appId) }
63+
.getOrElse { OpenIapStore(appContext, "auto", appId) }
64+
}
5965
val products by iapStore.products.collectAsState()
6066
val purchases by iapStore.availablePurchases.collectAsState()
6167
val status by iapStore.status.collectAsState()
@@ -96,7 +102,7 @@ fun SubscriptionFlowScreen(
96102
LaunchedEffect(purchases) {
97103
val map = mutableMapOf<String, SubscriptionUiInfo>()
98104
purchases
99-
.filter { it.productId in IapConstants.SUBS_SKUS }
105+
.filter { it.productId in IapConstants.subsSkus() }
100106
.forEach { p ->
101107
val token = p.purchaseToken ?: return@forEach
102108
val info = fetchSubStatusFromServer(p.productId, token)
@@ -118,9 +124,9 @@ fun SubscriptionFlowScreen(
118124
val connected = iapStore.initConnection()
119125
if (connected) {
120126
iapStore.setActivity(activity)
121-
println("SubscriptionFlow: Loading subscription products: ${IapConstants.SUBS_SKUS}")
127+
println("SubscriptionFlow: Loading subscription products: ${IapConstants.subsSkus()}")
122128
iapStore.fetchProducts(
123-
skus = IapConstants.SUBS_SKUS,
129+
skus = IapConstants.subsSkus(),
124130
type = ProductRequest.ProductRequestType.Subs
125131
)
126132
iapStore.getAvailablePurchases()
@@ -154,7 +160,7 @@ fun SubscriptionFlowScreen(
154160
try {
155161
iapStore.setActivity(activity)
156162
iapStore.fetchProducts(
157-
skus = IapConstants.SUBS_SKUS,
163+
skus = IapConstants.subsSkus(),
158164
type = ProductRequest.ProductRequestType.Subs
159165
)
160166
} catch (_: Exception) { }
@@ -247,7 +253,7 @@ fun SubscriptionFlowScreen(
247253

248254
// Active Subscriptions Section
249255
// Treat any purchase with matching subscription SKU as subscribed
250-
val activeSubscriptions = purchases.filter { it.productId in IapConstants.SUBS_SKUS }
256+
val activeSubscriptions = purchases.filter { it.productId in IapConstants.subsSkus() }
251257
if (activeSubscriptions.isNotEmpty()) {
252258
item {
253259
SectionHeaderView(title = "Active Subscriptions")
@@ -454,5 +460,3 @@ fun SubscriptionFlowScreen(
454460
)
455461
}
456462
}
457-
458-
// Moved to reusable component at: screens/uis/ActiveSubscriptionListItem.kt

README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,77 @@ dependencies {
6060
}
6161
```
6262

63+
## 🧩 Using From Framework Libraries
64+
65+
- Depend on this artifact from your framework wrapper (Expo/React Native, KMP) and inject the protocol into your bridge.
66+
- Create an instance with `OpenIapStore(context)` or inject the lower-level `OpenIapProtocol` if you need more control.
67+
- Forward your host `Activity` into the store via `setActivity(activity)` before `requestPurchase`.
68+
69+
Example in a native module bridge:
70+
71+
```kotlin
72+
private val store by lazy { OpenIapStore(reactApplicationContext) }
73+
74+
@ReactMethod
75+
fun bindActivity() {
76+
currentActivity?.let { store.setActivity(it) }
77+
}
78+
```
79+
80+
### Provider Selection (simple by default)
81+
82+
- Use `OpenIapStore(context, store?, appId?)` to select a provider.
83+
- Configuration: reads `BuildConfig.OPENIAP_STORE` with values:
84+
- `"play"` (default): Google Play Billing
85+
- `"horizon"`: Meta Horizon (requires horizon artifact in classpath)
86+
- `"auto"`: detect at runtime; prefers Horizon on Quest devices if available, else Play
87+
88+
Default setup:
89+
90+
- This library ships as a single-variant ("play") by default. No flavors needed.
91+
- `OPENIAP_STORE` defaults to `"play"`; you can set `"auto"` or `"horizon"` if your app includes those providers.
92+
93+
Override in your app or config plugin:
94+
95+
```kotlin
96+
android {
97+
defaultConfig {
98+
// "play" | "horizon" | "auto"
99+
buildConfigField("String", "OPENIAP_STORE", "\"auto\"")
100+
}
101+
}
102+
```
103+
104+
Supporting multiple stores:
105+
106+
- Runtime auto: include both provider artifacts in your wrapper/app and set `OPENIAP_STORE="auto"`. The factory detects a Horizon/Quest env and uses Horizon if present, otherwise Play.
107+
- Optional flavors (scale later): define a `store` flavor dimension in your app/framework repos when you need multiple providers (e.g., `play`, `horizon`, `another1`). Keep this core library single-variant by default.
108+
109+
### Horizon Quickstart
110+
111+
Run the Example app on Horizon/Quest without flavors:
112+
113+
- Build with a store hint: `./gradlew :Example:installDebug -PEXAMPLE_OPENIAP_STORE=auto`
114+
- Or force Horizon: `./gradlew :Example:installDebug -PEXAMPLE_OPENIAP_STORE=horizon -PEXAMPLE_HORIZON_APP_ID=YOUR_APP_ID`
115+
- Note: This library bundles the Horizon compatibility provider when configured; ensure you set `HORIZON_APP_ID`.
116+
117+
Integrate Horizon in your app/framework:
118+
119+
- Add the Horizon provider dependency (example coordinates):
120+
- Gradle: `implementation("io.github.hyochan.openiap:openiap-horizon:<version>")`
121+
- Or include the module and expose the factory class named above.
122+
- Select provider:
123+
- BuildConfig: `buildConfigField("String", "OPENIAP_STORE", "\"horizon\"")`
124+
- BuildConfig (Horizon only): `buildConfigField("String", "HORIZON_APP_ID", "\"YOUR_APP_ID\"")`
125+
- Or runtime: `OpenIapStore(context, "horizon", "YOUR_APP_ID")` or `OpenIapStore(context, "auto")`.
126+
- Device notes:
127+
- Use a Quest device; emulators won’t have Meta services.
128+
- Ensure the right Horizon/Meta services are installed and account is signed in.
129+
130+
### Flavor Setup (optional)
131+
132+
When you need multiple store-specific builds, add a `store` dimension in your app/framework and put provider dependencies under `playImplementation`, `horizonImplementation`, etc. Keep this core lib unchanged and lean.
133+
63134
## 🚀 Quick Start
64135

65136
### 1. Initialize in Application
@@ -333,6 +404,11 @@ The included sample app demonstrates:
333404
- ✅ Error handling and user feedback
334405
- ✅ Android-specific billing features
335406

407+
Optional: run Example on Horizon/Quest
408+
409+
- Build with a store hint: `./gradlew :Example:installDebug -PEXAMPLE_OPENIAP_STORE=auto`
410+
- Values: `play` (default) | `auto` | `horizon` (requires horizon provider on classpath)
411+
336412
## 🔧 Advanced Usage
337413

338414
### Custom Error Handling

openiap/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ plugins {
55
id("com.vanniktech.maven.publish")
66
}
77

8+
// Keep minimal: single-variant by default; BuildConfig flag added below
9+
810
// Resolve version from either 'openIapVersion' or 'OPENIAP_VERSION' or fallback
911
val openIapVersion: String =
1012
(project.findProperty("openIapVersion")
@@ -20,6 +22,10 @@ android {
2022

2123
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2224
consumerProguardFiles("consumer-rules.pro")
25+
// Minimal provider selection (default: play)
26+
buildConfigField("String", "OPENIAP_STORE", "\"play\"")
27+
// Optional Horizon app id (provider-specific). Empty by default.
28+
buildConfigField("String", "HORIZON_APP_ID", "\"\"")
2329
}
2430

2531
buildTypes {
@@ -44,6 +50,7 @@ android {
4450
// Enable Compose for composables in this library (IapContext)
4551
buildFeatures {
4652
compose = true
53+
buildConfig = true
4754
}
4855
}
4956

@@ -53,6 +60,11 @@ dependencies {
5360

5461
// Google Play Billing Library (align with app/lib v8)
5562
api("com.android.billingclient:billing-ktx:8.0.0")
63+
64+
// Meta Horizon Billing Compatibility SDK (optional provider)
65+
implementation("com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1")
66+
// Meta Horizon Platform SDK (required alongside billing compat)
67+
implementation("com.meta.horizon.platform.ovr:android-platform-sdk:72")
5668

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

0 commit comments

Comments
 (0)