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

Commit cd3ba98

Browse files
committed
feat: support multi-SKU purchase flow
Validate empty SKU list, resolve ProductDetails per SKU using cache + query, build ProductDetailsParams list for all requested SKUs, and surface SKU_NOT_FOUND/SKU_OFFER_MISMATCH errors before launching billing flow.
1 parent 59ba42e commit cd3ba98

File tree

1 file changed

+57
-31
lines changed

1 file changed

+57
-31
lines changed

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

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -150,33 +150,44 @@ class OpenIapModule(private val context: Context) : OpenIapProtocol, PurchasesUp
150150
return@suspendCancellableCoroutine
151151
}
152152

153-
// Prefer cached details if available
154-
val cachedDetails = request.skus
155-
.mapNotNull { sku -> productManager.get(sku) }
156-
.firstOrNull { it.productType == desiredType }
157-
158-
val useDetails: (ProductDetails) -> Unit = details@{ productDetails ->
159-
Log.d(TAG, "Using ProductDetails: sku=${productDetails.productId}, title=${productDetails.title}, type=$type")
160-
val pdParamsBuilder = BillingFlowParams.ProductDetailsParams.newBuilder()
161-
.setProductDetails(productDetails)
162-
if (type == ProductRequest.ProductRequestType.SUBS) {
163-
val offerToken = productDetails.subscriptionOfferDetails
164-
?.firstOrNull()
165-
?.offerToken
166-
if (offerToken.isNullOrEmpty()) {
167-
Log.w(TAG, "No subscription offer available for ${request.skus}")
168-
val err = OpenIapError.SkuOfferMismatch
169-
purchaseErrorListeners.forEach { runCatching { it.onPurchaseError(err) } }
170-
currentPurchaseCallback?.invoke(Result.success(emptyList()))
171-
return@details
153+
// Validate SKU list
154+
if (request.skus.isEmpty()) {
155+
val err = OpenIapError.EmptySkuList
156+
purchaseErrorListeners.forEach { runCatching { it.onPurchaseError(err) } }
157+
currentPurchaseCallback?.invoke(Result.success(emptyList()))
158+
return@suspendCancellableCoroutine
159+
}
160+
161+
// Resolve details from cache first
162+
val detailsBySku = mutableMapOf<String, ProductDetails>()
163+
request.skus.forEach { sku ->
164+
val d = productManager.get(sku)
165+
if (d != null && d.productType == desiredType) detailsBySku[sku] = d
166+
}
167+
val missingSkus = request.skus.filter { !detailsBySku.containsKey(it) }
168+
169+
fun launchBillingWith(detailsList: List<ProductDetails>) {
170+
// Build params for all requested SKUs in order
171+
val paramsList = mutableListOf<BillingFlowParams.ProductDetailsParams>()
172+
for (pd in detailsList) {
173+
val builder = BillingFlowParams.ProductDetailsParams.newBuilder()
174+
.setProductDetails(pd)
175+
if (type == ProductRequest.ProductRequestType.SUBS) {
176+
val offerToken = pd.subscriptionOfferDetails?.firstOrNull()?.offerToken
177+
if (offerToken.isNullOrEmpty()) {
178+
Log.w(TAG, "No subscription offer available for ${pd.productId}")
179+
val err = OpenIapError.SkuOfferMismatch
180+
purchaseErrorListeners.forEach { runCatching { it.onPurchaseError(err) } }
181+
currentPurchaseCallback?.invoke(Result.success(emptyList()))
182+
return
183+
}
184+
builder.setOfferToken(offerToken)
172185
}
173-
Log.d(TAG, "Using offerToken=$offerToken for SUBS purchase")
174-
pdParamsBuilder.setOfferToken(offerToken)
186+
paramsList.add(builder.build())
175187
}
176-
val productDetailsParamsList = listOf(pdParamsBuilder.build())
177188

178189
val billingFlowParams = BillingFlowParams.newBuilder()
179-
.setProductDetailsParamsList(productDetailsParamsList)
190+
.setProductDetailsParamsList(paramsList)
180191
.setIsOfferPersonalized(request.isOfferPersonalized == true)
181192
.apply {
182193
request.obfuscatedAccountIdAndroid?.let { setObfuscatedAccountId(it) }
@@ -193,11 +204,19 @@ class OpenIapModule(private val context: Context) : OpenIapProtocol, PurchasesUp
193204
}
194205
}
195206

196-
if (cachedDetails != null) {
197-
useDetails(cachedDetails)
207+
if (missingSkus.isEmpty()) {
208+
val ordered = request.skus.mapNotNull { detailsBySku[it] }
209+
if (ordered.size != request.skus.size) {
210+
val missing = request.skus.firstOrNull { !detailsBySku.containsKey(it) }
211+
val err = OpenIapError.SkuNotFound(missing ?: "")
212+
purchaseErrorListeners.forEach { runCatching { it.onPurchaseError(err) } }
213+
currentPurchaseCallback?.invoke(Result.success(emptyList()))
214+
return@suspendCancellableCoroutine
215+
}
216+
launchBillingWith(ordered)
198217
} else {
199-
// Query product details, update cache, then launch flow
200-
val productList = request.skus.map { sku ->
218+
// Query missing, update cache and then launch
219+
val productList = missingSkus.map { sku ->
201220
QueryProductDetailsParams.Product.newBuilder()
202221
.setProductId(sku)
203222
.setProductType(desiredType)
@@ -212,11 +231,18 @@ class OpenIapModule(private val context: Context) : OpenIapProtocol, PurchasesUp
212231
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK &&
213232
productDetailsList != null && productDetailsList.isNotEmpty()
214233
) {
215-
// Update cache
216234
productManager.putAll(productDetailsList)
217-
val productDetails = productDetailsList.first()
218-
useDetails(productDetails)
219-
// Do not complete here; wait for onPurchasesUpdated to resolve the result
235+
productDetailsList.forEach { detailsBySku[it.productId] = it }
236+
val ordered = request.skus.mapNotNull { detailsBySku[it] }
237+
if (ordered.size != request.skus.size) {
238+
val missing = request.skus.firstOrNull { !detailsBySku.containsKey(it) }
239+
val err = OpenIapError.SkuNotFound(missing ?: "")
240+
purchaseErrorListeners.forEach { runCatching { it.onPurchaseError(err) } }
241+
currentPurchaseCallback?.invoke(Result.success(emptyList()))
242+
return@queryProductDetailsAsync
243+
}
244+
launchBillingWith(ordered)
245+
// Do not complete here; wait for onPurchasesUpdated
220246
} else {
221247
Log.w(TAG, "queryProductDetails failed: code=${billingResult.responseCode} msg=${billingResult.debugMessage}")
222248
val err = OpenIapError.QueryProduct(billingResult.debugMessage ?: "Product not found: ${request.skus}")

0 commit comments

Comments
 (0)