Skip to content

Commit cdb5fd0

Browse files
authored
fix(horizon): initialize Billing with Activity to prevent Null Exception (#27)
Resolve meta-quest/Meta-Spatial-SDK-Samples#82 (comment) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Updated Horizon platform SDK dependency to version 77.0.1. * **Refactor** * Improved billing client initialization and activity-aware handling for more reliable purchase flows. * Centralized and standardized internal logging to improve diagnostics and error visibility. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a3a2f2b commit cdb5fd0

2 files changed

Lines changed: 39 additions & 57 deletions

File tree

packages/google/openiap/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ dependencies {
9090
add("playApi", "com.android.billingclient:billing-ktx:8.0.0")
9191

9292
// Horizon flavor: Meta Horizon Platform SDK and Billing Compatibility Library (compile + runtime)
93-
add("horizonCompileOnly", "com.meta.horizon.platform.ovr:android-platform-sdk:72")
94-
add("horizonApi", "com.meta.horizon.platform.ovr:android-platform-sdk:72")
93+
add("horizonCompileOnly", "com.meta.horizon.platform.ovr:android-platform-sdk:77.0.1")
94+
add("horizonApi", "com.meta.horizon.platform.ovr:android-platform-sdk:77.0.1")
9595
add("horizonCompileOnly", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1")
9696
add("horizonApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1")
9797

packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 37 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,10 @@ class OpenIapModule(
7979
android.content.pm.PackageManager.GET_META_DATA
8080
)
8181
val id = appInfo.metaData?.getString("com.oculus.vr.APP_ID")
82-
android.util.Log.i(TAG, "Read Oculus App ID from manifest: $id")
82+
OpenIapLog.d("Read Oculus App ID from manifest: $id", TAG)
8383
id
8484
} catch (e: Exception) {
85-
android.util.Log.w(TAG, "Failed to read com.oculus.vr.APP_ID from AndroidManifest.xml: ${e.message}")
85+
OpenIapLog.w("Failed to read com.oculus.vr.APP_ID from AndroidManifest.xml: ${e.message}", TAG)
8686
null
8787
}
8888
}
@@ -98,8 +98,9 @@ class OpenIapModule(
9898
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
9999

100100
init {
101-
android.util.Log.i(TAG, "=== OpenIapModule INIT (Horizon flavor) ===")
102-
buildBillingClient()
101+
// DO NOT build BillingClient here - React Native context doesn't have Activity yet
102+
// BillingClient will be built in initConnection() when Activity is guaranteed to be available
103+
OpenIapLog.d("OpenIapModule initialized (Horizon flavor)", TAG)
103104
}
104105

105106
override fun setActivity(activity: Activity?) {
@@ -109,37 +110,35 @@ class OpenIapModule(
109110
override val initConnection: MutationInitConnectionHandler = {
110111
withContext(Dispatchers.IO) {
111112
suspendCancellableCoroutine<Boolean> { continuation ->
112-
android.util.Log.i(TAG, "=== INIT CONNECTION CALLED ===")
113+
OpenIapLog.i("=== INIT CONNECTION ===", TAG)
113114

114115
// CRITICAL FIX: Rebuild BillingClient if it was destroyed by endConnection
116+
// Use current Activity if available, otherwise fallback to Context
115117
if (billingClient == null) {
116-
android.util.Log.i(TAG, "BillingClient is null, rebuilding...")
117-
buildBillingClient()
118-
} else {
119-
android.util.Log.i(TAG, "BillingClient already exists, using existing instance")
118+
val contextForInit = currentActivityRef?.get() ?: fallbackActivity ?: context
119+
OpenIapLog.d("Building BillingClient with ${contextForInit.javaClass.simpleName}...", TAG)
120+
buildBillingClient(contextForInit)
120121
}
121122

122123
val client = billingClient ?: run {
123-
android.util.Log.w(TAG, "Failed to build BillingClient")
124+
OpenIapLog.w("Failed to build BillingClient", TAG)
124125
if (continuation.isActive) continuation.resume(false)
125126
return@suspendCancellableCoroutine
126127
}
127128

128-
android.util.Log.i(TAG, "Starting BillingClient connection...")
129129
client.startConnection(object : BillingClientStateListener {
130130
override fun onBillingSetupFinished(result: BillingResult) {
131-
android.util.Log.i(TAG, "onBillingSetupFinished: code=${result.responseCode}, message=${result.debugMessage}")
132131
val ok = result.responseCode == BillingClient.BillingResponseCode.OK
133132
if (!ok) {
134-
android.util.Log.w(TAG, "Horizon setup failed: code=${result.responseCode}, ${result.debugMessage}")
133+
OpenIapLog.w("Horizon setup failed: code=${result.responseCode}, ${result.debugMessage}", TAG)
135134
} else {
136-
android.util.Log.i(TAG, "Horizon billing connected successfully!")
135+
OpenIapLog.i("Horizon billing connected successfully", TAG)
137136
}
138137
if (continuation.isActive) continuation.resume(ok)
139138
}
140139

141140
override fun onBillingServiceDisconnected() {
142-
android.util.Log.i(TAG, "Horizon service disconnected")
141+
OpenIapLog.i("Horizon service disconnected", TAG)
143142
}
144143
})
145144
}
@@ -212,82 +211,63 @@ class OpenIapModule(
212211
}
213212

214213
override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ ->
215-
android.util.Log.i("HORIZON_QUERY", "getAvailablePurchases BEFORE withContext")
216214
withContext(Dispatchers.IO) {
217-
android.util.Log.i("HORIZON_QUERY", "=== getAvailablePurchases INSIDE withContext ===")
218215
OpenIapLog.i("=== HORIZON getAvailablePurchases ===", TAG)
219216

220217
val purchases = restorePurchasesHorizon(billingClient)
221-
android.util.Log.i("HORIZON_QUERY", "Retrieved ${purchases.size} purchases from query")
222218
OpenIapLog.i("Retrieved ${purchases.size} total purchases (INAPP + SUBS)", TAG)
223219

224220
// CRITICAL FIX: Merge with cached purchases
225221
val cachedPurchases = sharedPurchaseCache.values.toList()
226-
android.util.Log.i("HORIZON_QUERY", "Cached purchases: ${cachedPurchases.size}")
227222

228223
// Combine query results with cache, preferring query results
229224
val purchaseMap = mutableMapOf<String, Purchase>()
230225
cachedPurchases.forEach { purchaseMap[it.productId] = it }
231226
purchases.forEach { purchaseMap[it.productId] = it } // Override with fresh data
232227

233228
val allPurchases = purchaseMap.values.toList()
234-
android.util.Log.i("HORIZON_QUERY", "Total purchases (query + cache): ${allPurchases.size}")
235229

236230
allPurchases.forEachIndexed { index, purchase ->
237231
val txnId = when (purchase) {
238232
is dev.hyo.openiap.PurchaseAndroid -> purchase.transactionId
239233
else -> "N/A"
240234
}
241-
android.util.Log.i("HORIZON_QUERY", "Purchase[$index] productId=${purchase.productId} txnId=$txnId")
242235
OpenIapLog.i(
243236
" [$index] productId=${purchase.productId} " +
244237
"transactionId=$txnId " +
245238
"platform=${purchase.platform}",
246239
TAG
247240
)
248241
}
249-
android.util.Log.i("HORIZON_QUERY", "=== getAvailablePurchases END ===")
250242
OpenIapLog.i("=== END getAvailablePurchases ===", TAG)
251243
allPurchases
252244
}
253245
}
254246

255247
override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds ->
256248
withContext(Dispatchers.IO) {
257-
android.util.Log.i("HORIZON_QUERY", "=== getActiveSubscriptions START ===")
258-
android.util.Log.i("HORIZON_QUERY", "Requested IDs: $subscriptionIds")
259249
OpenIapLog.i("=== HORIZON getActiveSubscriptions ===", TAG)
260250
OpenIapLog.i("Requested subscriptionIds: $subscriptionIds", TAG)
261251

262252
val allPurchases = queryPurchasesHorizon(billingClient, BillingClient.ProductType.SUBS)
263-
android.util.Log.i("HORIZON_QUERY", "Raw query returned ${allPurchases.size} SUBS purchases")
264253
OpenIapLog.i("Total SUBS purchases from query: ${allPurchases.size}", TAG)
265254

266-
allPurchases.forEachIndexed { index, purchase ->
267-
android.util.Log.i("HORIZON_QUERY", "RawPurchase[$index] productId=${purchase.productId} type=${purchase.javaClass.simpleName}")
268-
}
269-
270255
val androidPurchases = allPurchases.filterIsInstance<PurchaseAndroid>()
271-
android.util.Log.i("HORIZON_QUERY", "Filtered to ${androidPurchases.size} PurchaseAndroid instances")
272256
OpenIapLog.i("PurchaseAndroid instances: ${androidPurchases.size}", TAG)
273257

274258
val ids = subscriptionIds.orEmpty()
275259
val filtered = if (ids.isEmpty()) {
276-
android.util.Log.i("HORIZON_QUERY", "No filter - returning all")
277260
OpenIapLog.i("No filter - returning all subscriptions", TAG)
278261
androidPurchases
279262
} else {
280-
android.util.Log.i("HORIZON_QUERY", "Filtering by IDs: $ids")
281263
OpenIapLog.i("Filtering by IDs: $ids", TAG)
282264
androidPurchases.filter { it.productId in ids }
283265
}
284266

285-
android.util.Log.i("HORIZON_QUERY", "After filtering: ${filtered.size} subscriptions")
286267
OpenIapLog.i("Filtered subscriptions count: ${filtered.size}", TAG)
287268
val activeSubscriptions = filtered.map { it.toActiveSubscription() }
288269

289270
activeSubscriptions.forEachIndexed { index, sub ->
290-
android.util.Log.i("HORIZON_QUERY", "ActiveSub[$index] productId=${sub.productId} active=${sub.isActive}")
291271
OpenIapLog.i(
292272
" [$index] productId=${sub.productId} " +
293273
"isActive=${sub.isActive} " +
@@ -296,7 +276,6 @@ class OpenIapModule(
296276
)
297277
}
298278

299-
android.util.Log.i("HORIZON_QUERY", "=== getActiveSubscriptions END - returning ${activeSubscriptions.size} ===")
300279
OpenIapLog.i("=== END getActiveSubscriptions ===", TAG)
301280
activeSubscriptions
302281
}
@@ -309,6 +288,8 @@ class OpenIapModule(
309288
override val requestPurchase: MutationRequestPurchaseHandler = { props ->
310289
val purchases = withContext(Dispatchers.IO) {
311290
val androidArgs = props.toAndroidPurchaseArgs()
291+
OpenIapLog.i("=== REQUEST PURCHASE: ${androidArgs.skus} ===", TAG)
292+
312293
val activity = currentActivityRef?.get() ?: fallbackActivity
313294

314295
if (activity == null) {
@@ -430,16 +411,10 @@ class OpenIapModule(
430411
}
431412

432413
val billingFlowParams = flowBuilder.build()
433-
android.util.Log.i(TAG, "=== LAUNCHING BILLING FLOW ===")
434-
android.util.Log.i(TAG, " - Is subscription? ${androidArgs.type == ProductQueryType.Subs}")
435-
android.util.Log.i(TAG, " - Has purchaseToken? ${!androidArgs.purchaseTokenAndroid.isNullOrBlank()}")
436414

437415
// Run on UI thread as required by Android Billing API
438416
activity.runOnUiThread {
439417
val result = client.launchBillingFlow(activity, billingFlowParams)
440-
android.util.Log.i(TAG, "=== BILLING FLOW LAUNCHED ===")
441-
android.util.Log.i(TAG, " - Response code: ${result.responseCode}")
442-
android.util.Log.i(TAG, " - Debug message: ${result.debugMessage}")
443418
OpenIapLog.d("launchBillingFlow result: ${result.responseCode} - ${result.debugMessage}", TAG)
444419

445420
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
@@ -468,7 +443,7 @@ class OpenIapModule(
468443
}
469444

470445
if (filtered.isNotEmpty()) {
471-
android.util.Log.i(TAG, "Proactive query found ${filtered.size} purchases")
446+
OpenIapLog.d("Proactive query found ${filtered.size} purchases", TAG)
472447
filtered.forEach { purchase ->
473448
purchaseUpdateListeners.forEach { listener ->
474449
runCatching { listener.onPurchaseUpdated(purchase) }
@@ -477,7 +452,7 @@ class OpenIapModule(
477452
currentPurchaseCallback?.invoke(Result.success(filtered))
478453
}
479454
} catch (e: Exception) {
480-
android.util.Log.e(TAG, "Error in proactive purchase query: ${e.message}")
455+
OpenIapLog.e("Error in proactive purchase query", e, TAG)
481456
}
482457
}
483458
}
@@ -701,19 +676,14 @@ class OpenIapModule(
701676
}
702677

703678
override fun onPurchasesUpdated(result: BillingResult, purchases: List<HorizonPurchase>?) {
704-
// Log with Android Log to ensure it appears even if OpenIapLog fails
705-
android.util.Log.wtf("HORIZON_CALLBACK", "onPurchasesUpdated START - responseCode=${result.responseCode}, count=${purchases?.size ?: 0}")
706-
707679
try {
708680
OpenIapLog.i("=== HORIZON onPurchasesUpdated ===", TAG)
709681
OpenIapLog.i("Response code: ${result.responseCode}", TAG)
710-
OpenIapLog.i("Debug message: ${result.debugMessage}", TAG)
711682
OpenIapLog.i("Purchases count: ${purchases?.size ?: 0}", TAG)
712683

713684
purchases?.forEachIndexed { index, purchase ->
714685
val redactedToken = purchase.purchaseToken?.take(8)?.plus("")
715686
val redactedOrder = purchase.orderId?.take(8)?.plus("")
716-
android.util.Log.i("HORIZON_CALLBACK", "Purchase[$index] products=${purchase.products} token=$redactedToken")
717687
OpenIapLog.i(
718688
"[HorizonPurchase $index] productIds=${purchase.products} token=$redactedToken orderId=$redactedOrder " +
719689
"acknowledged=${purchase.isAcknowledged()} autoRenew=${purchase.isAutoRenewing()}",
@@ -725,7 +695,6 @@ class OpenIapModule(
725695
// When using DEFERRED replacement mode, purchases will be null
726696
// This is expected behavior - the change will take effect at next renewal
727697
if (purchases != null) {
728-
android.util.Log.i("HORIZON_CALLBACK", "Processing ${purchases.size} purchases")
729698
OpenIapLog.i("Processing ${purchases.size} successful purchases", TAG)
730699

731700
val mapped = purchases.map { purchase ->
@@ -776,25 +745,22 @@ class OpenIapModule(
776745
OpenIapLog.i("Purchase callback invoked", TAG)
777746
} else {
778747
// Purchases is null - likely DEFERRED mode
779-
android.util.Log.d("HORIZON_CALLBACK", "Purchase successful but purchases list is null (DEFERRED mode)")
780748
OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG)
781749
currentPurchaseCallback?.let { cb ->
782750
currentPurchaseCallback = null
783751
cb.invoke(Result.success(emptyList()))
784752
}
785753
}
786754
} else {
787-
android.util.Log.w("HORIZON_CALLBACK", "Purchase failed: code=${result.responseCode}")
788755
OpenIapLog.w("Purchase failed or cancelled: code=${result.responseCode}", TAG)
789756
val error = OpenIapError.fromBillingResponseCode(result.responseCode, result.debugMessage)
790757
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(error) } }
791758
currentPurchaseCallback?.invoke(Result.success(emptyList()))
792759
}
793760
currentPurchaseCallback = null
794-
android.util.Log.i("HORIZON_CALLBACK", "onPurchasesUpdated END")
795761
OpenIapLog.i("=== END onPurchasesUpdated ===", TAG)
796762
} catch (e: Exception) {
797-
android.util.Log.e("HORIZON_CALLBACK", "Exception in onPurchasesUpdated", e)
763+
OpenIapLog.e("Exception in onPurchasesUpdated", e, TAG)
798764
}
799765
}
800766

@@ -820,20 +786,36 @@ class OpenIapModule(
820786
}
821787
}
822788

823-
private fun buildBillingClient() {
789+
/**
790+
* Build BillingClient with the provided context.
791+
*
792+
* CRITICAL: Horizon SDK requires Activity to properly initialize OVRPlatform with returnComponent.
793+
* If Context (non-Activity) is provided, Horizon SDK will run in limited mode and may cause
794+
* NullPointerException during purchase flow.
795+
*
796+
* @param contextForBilling Activity (preferred) or Application Context (fallback)
797+
*/
798+
private fun buildBillingClient(contextForBilling: Context) {
799+
if (contextForBilling is Activity) {
800+
OpenIapLog.d("Building BillingClient with Activity", TAG)
801+
} else {
802+
OpenIapLog.w("Building BillingClient with Context (not Activity) - Horizon SDK will run in limited mode", TAG)
803+
}
804+
824805
val pendingPurchasesParams = com.meta.horizon.billingclient.api.PendingPurchasesParams.newBuilder()
825806
.enableOneTimeProducts()
826807
.build()
827808

828809
val builder = BillingClient
829-
.newBuilder(context)
810+
.newBuilder(contextForBilling)
830811
.setListener(this)
831812
.enablePendingPurchases(pendingPurchasesParams)
832813

833814
// Set app ID if available from manifest
834815
appId?.let { id ->
835816
if (id.isNotEmpty()) {
836817
builder.setAppId(id)
818+
OpenIapLog.d("Horizon App ID set: $id", TAG)
837819
}
838820
}
839821

0 commit comments

Comments
 (0)