Skip to content
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
4 changes: 2 additions & 2 deletions packages/google/openiap/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ dependencies {
add("playApi", "com.android.billingclient:billing-ktx:8.0.0")

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ class OpenIapModule(
android.content.pm.PackageManager.GET_META_DATA
)
val id = appInfo.metaData?.getString("com.oculus.vr.APP_ID")
android.util.Log.i(TAG, "Read Oculus App ID from manifest: $id")
OpenIapLog.d("Read Oculus App ID from manifest: $id", TAG)
id
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to read com.oculus.vr.APP_ID from AndroidManifest.xml: ${e.message}")
OpenIapLog.w("Failed to read com.oculus.vr.APP_ID from AndroidManifest.xml: ${e.message}", TAG)
null
}
}
Expand All @@ -98,8 +98,9 @@ class OpenIapModule(
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()

init {
android.util.Log.i(TAG, "=== OpenIapModule INIT (Horizon flavor) ===")
buildBillingClient()
// DO NOT build BillingClient here - React Native context doesn't have Activity yet
// BillingClient will be built in initConnection() when Activity is guaranteed to be available
OpenIapLog.d("OpenIapModule initialized (Horizon flavor)", TAG)
}
Comment thread
hyochan marked this conversation as resolved.

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

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

val client = billingClient ?: run {
android.util.Log.w(TAG, "Failed to build BillingClient")
OpenIapLog.w("Failed to build BillingClient", TAG)
if (continuation.isActive) continuation.resume(false)
return@suspendCancellableCoroutine
}

android.util.Log.i(TAG, "Starting BillingClient connection...")
client.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
android.util.Log.i(TAG, "onBillingSetupFinished: code=${result.responseCode}, message=${result.debugMessage}")
val ok = result.responseCode == BillingClient.BillingResponseCode.OK
if (!ok) {
android.util.Log.w(TAG, "Horizon setup failed: code=${result.responseCode}, ${result.debugMessage}")
OpenIapLog.w("Horizon setup failed: code=${result.responseCode}, ${result.debugMessage}", TAG)
} else {
android.util.Log.i(TAG, "Horizon billing connected successfully!")
OpenIapLog.i("Horizon billing connected successfully", TAG)
}
if (continuation.isActive) continuation.resume(ok)
}

override fun onBillingServiceDisconnected() {
android.util.Log.i(TAG, "Horizon service disconnected")
OpenIapLog.i("Horizon service disconnected", TAG)
}
})
}
Expand Down Expand Up @@ -212,82 +211,63 @@ class OpenIapModule(
}

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

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

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

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

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

allPurchases.forEachIndexed { index, purchase ->
val txnId = when (purchase) {
is dev.hyo.openiap.PurchaseAndroid -> purchase.transactionId
else -> "N/A"
}
android.util.Log.i("HORIZON_QUERY", "Purchase[$index] productId=${purchase.productId} txnId=$txnId")
OpenIapLog.i(
" [$index] productId=${purchase.productId} " +
"transactionId=$txnId " +
"platform=${purchase.platform}",
TAG
)
}
android.util.Log.i("HORIZON_QUERY", "=== getAvailablePurchases END ===")
OpenIapLog.i("=== END getAvailablePurchases ===", TAG)
allPurchases
}
}

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

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

allPurchases.forEachIndexed { index, purchase ->
android.util.Log.i("HORIZON_QUERY", "RawPurchase[$index] productId=${purchase.productId} type=${purchase.javaClass.simpleName}")
}

val androidPurchases = allPurchases.filterIsInstance<PurchaseAndroid>()
android.util.Log.i("HORIZON_QUERY", "Filtered to ${androidPurchases.size} PurchaseAndroid instances")
OpenIapLog.i("PurchaseAndroid instances: ${androidPurchases.size}", TAG)

val ids = subscriptionIds.orEmpty()
val filtered = if (ids.isEmpty()) {
android.util.Log.i("HORIZON_QUERY", "No filter - returning all")
OpenIapLog.i("No filter - returning all subscriptions", TAG)
androidPurchases
} else {
android.util.Log.i("HORIZON_QUERY", "Filtering by IDs: $ids")
OpenIapLog.i("Filtering by IDs: $ids", TAG)
androidPurchases.filter { it.productId in ids }
}

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

activeSubscriptions.forEachIndexed { index, sub ->
android.util.Log.i("HORIZON_QUERY", "ActiveSub[$index] productId=${sub.productId} active=${sub.isActive}")
OpenIapLog.i(
" [$index] productId=${sub.productId} " +
"isActive=${sub.isActive} " +
Expand All @@ -296,7 +276,6 @@ class OpenIapModule(
)
}

android.util.Log.i("HORIZON_QUERY", "=== getActiveSubscriptions END - returning ${activeSubscriptions.size} ===")
OpenIapLog.i("=== END getActiveSubscriptions ===", TAG)
activeSubscriptions
}
Expand All @@ -309,6 +288,8 @@ class OpenIapModule(
override val requestPurchase: MutationRequestPurchaseHandler = { props ->
val purchases = withContext(Dispatchers.IO) {
val androidArgs = props.toAndroidPurchaseArgs()
OpenIapLog.i("=== REQUEST PURCHASE: ${androidArgs.skus} ===", TAG)

val activity = currentActivityRef?.get() ?: fallbackActivity

if (activity == null) {
Expand Down Expand Up @@ -430,16 +411,10 @@ class OpenIapModule(
}

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

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

if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Expand Down Expand Up @@ -468,7 +443,7 @@ class OpenIapModule(
}

if (filtered.isNotEmpty()) {
android.util.Log.i(TAG, "Proactive query found ${filtered.size} purchases")
OpenIapLog.d("Proactive query found ${filtered.size} purchases", TAG)
filtered.forEach { purchase ->
purchaseUpdateListeners.forEach { listener ->
runCatching { listener.onPurchaseUpdated(purchase) }
Expand All @@ -477,7 +452,7 @@ class OpenIapModule(
currentPurchaseCallback?.invoke(Result.success(filtered))
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Error in proactive purchase query: ${e.message}")
OpenIapLog.e("Error in proactive purchase query", e, TAG)
}
}
}
Expand Down Expand Up @@ -701,19 +676,14 @@ class OpenIapModule(
}

override fun onPurchasesUpdated(result: BillingResult, purchases: List<HorizonPurchase>?) {
// Log with Android Log to ensure it appears even if OpenIapLog fails
android.util.Log.wtf("HORIZON_CALLBACK", "onPurchasesUpdated START - responseCode=${result.responseCode}, count=${purchases?.size ?: 0}")

try {
OpenIapLog.i("=== HORIZON onPurchasesUpdated ===", TAG)
OpenIapLog.i("Response code: ${result.responseCode}", TAG)
OpenIapLog.i("Debug message: ${result.debugMessage}", TAG)
OpenIapLog.i("Purchases count: ${purchases?.size ?: 0}", TAG)

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

val mapped = purchases.map { purchase ->
Expand Down Expand Up @@ -776,25 +745,22 @@ class OpenIapModule(
OpenIapLog.i("Purchase callback invoked", TAG)
} else {
// Purchases is null - likely DEFERRED mode
android.util.Log.d("HORIZON_CALLBACK", "Purchase successful but purchases list is null (DEFERRED mode)")
OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG)
currentPurchaseCallback?.let { cb ->
currentPurchaseCallback = null
cb.invoke(Result.success(emptyList()))
}
}
} else {
android.util.Log.w("HORIZON_CALLBACK", "Purchase failed: code=${result.responseCode}")
OpenIapLog.w("Purchase failed or cancelled: code=${result.responseCode}", TAG)
val error = OpenIapError.fromBillingResponseCode(result.responseCode, result.debugMessage)
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(error) } }
currentPurchaseCallback?.invoke(Result.success(emptyList()))
}
currentPurchaseCallback = null
android.util.Log.i("HORIZON_CALLBACK", "onPurchasesUpdated END")
OpenIapLog.i("=== END onPurchasesUpdated ===", TAG)
} catch (e: Exception) {
android.util.Log.e("HORIZON_CALLBACK", "Exception in onPurchasesUpdated", e)
OpenIapLog.e("Exception in onPurchasesUpdated", e, TAG)
}
}

Expand All @@ -820,20 +786,36 @@ class OpenIapModule(
}
}

private fun buildBillingClient() {
/**
* Build BillingClient with the provided context.
*
* CRITICAL: Horizon SDK requires Activity to properly initialize OVRPlatform with returnComponent.
* If Context (non-Activity) is provided, Horizon SDK will run in limited mode and may cause
* NullPointerException during purchase flow.
*
* @param contextForBilling Activity (preferred) or Application Context (fallback)
*/
private fun buildBillingClient(contextForBilling: Context) {
if (contextForBilling is Activity) {
OpenIapLog.d("Building BillingClient with Activity", TAG)
} else {
OpenIapLog.w("Building BillingClient with Context (not Activity) - Horizon SDK will run in limited mode", TAG)
}

val pendingPurchasesParams = com.meta.horizon.billingclient.api.PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()

val builder = BillingClient
.newBuilder(context)
.newBuilder(contextForBilling)
.setListener(this)
.enablePendingPurchases(pendingPurchasesParams)

// Set app ID if available from manifest
appId?.let { id ->
if (id.isNotEmpty()) {
builder.setAppId(id)
OpenIapLog.d("Horizon App ID set: $id", TAG)
}
}

Expand Down
Loading