From 3cbc91330d9a37aa0a0b3da588ecc20001a61d61 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 00:57:59 +0900 Subject: [PATCH 1/7] fix(google): surface query product diagnostics Add structured QueryProduct diagnostics for Android product query failures, including Billing response code, debug message, queried product IDs, product type, and empty-result state. Propagate the diagnostics through React Native and MAUI bridges, and document the skipped openiap-google 2.1.3 release as openiap-google 2.1.4. --- .../dev/hyo/openiap/maui/OpenIapMauiModule.kt | 5 + .../java/com/margelo/nitro/iap/HybridRnIap.kt | 107 +++++++++--------- libraries/react-native-iap/src/index.ts | 13 ++- libraries/react-native-iap/src/utils/error.ts | 3 + .../src/utils/errorMapping.ts | 12 ++ packages/docs/src/pages/docs/errors.tsx | 10 ++ .../docs/src/pages/docs/updates/releases.tsx | 17 +-- .../java/dev/hyo/openiap/OpenIapModule.kt | 8 +- .../main/java/dev/hyo/openiap/OpenIapError.kt | 33 ++++++ .../java/dev/hyo/openiap/OpenIapModule.kt | 8 +- .../dev/hyo/openiap/helpers/ProductManager.kt | 10 +- .../java/dev/hyo/openiap/OpenIapErrorTest.kt | 27 ++++- 12 files changed, 185 insertions(+), 68 deletions(-) diff --git a/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt b/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt index eb0ca987..7826bb9e 100644 --- a/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt +++ b/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt @@ -402,6 +402,7 @@ class OpenIapMauiModule(context: Context) { * generated C# record expects. */ private fun encodeError(e: OpenIapError): Map { + val diagnostics = e.toJSON() val productId = when (e) { is OpenIapError.ProductNotFound -> e.productId else -> null @@ -411,6 +412,10 @@ class OpenIapMauiModule(context: Context) { "message" to e.message, "productId" to productId, "debugMessage" to e.debugMessage, + "responseCode" to diagnostics["responseCode"], + "productIds" to diagnostics["productIds"], + "productType" to diagnostics["productType"], + "isEmptyProductList" to diagnostics["isEmptyProductList"], ) } diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 34428bae..3a000bbc 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -180,15 +180,7 @@ class HybridRnIap : HybridRnIapSpec() { "purchaseErrorListener", mapOf("code" to code, "message" to message) ) - sendPurchaseError( - NitroPurchaseResult( - responseCode = -1.0, - debugMessage = null, - code = code, - message = message, - purchaseToken = null - ) - ) + sendPurchaseError(toErrorResult(e)) }.onFailure { RnIapLog.failure("purchaseErrorListener", it) } }) openIap.addUserChoiceBillingListener(OpenIapUserChoiceBillingListener { details -> @@ -343,46 +335,50 @@ class HybridRnIap : HybridRnIapSpec() { val queryType = parseProductQueryType(type) val skusList = skus.toList() - val products: List = when (queryType) { - ProductQueryType.All -> { - // Fetch both InApp and Subs products - val byId = mutableMapOf() + val products: List = try { + when (queryType) { + ProductQueryType.All -> { + // Fetch both InApp and Subs products + val byId = mutableMapOf() + + listOf(ProductQueryType.InApp, ProductQueryType.Subs).forEach { kind -> + RnIapLog.payload( + "fetchProducts.native", + mapOf("skus" to skusList, "type" to kind.rawValue) + ) + val fetched = openIap.fetchProducts(ProductRequest(skusList, kind)).productsOrEmpty() + RnIapLog.result( + "fetchProducts.native", + fetched.map { mapOf("id" to it.id, "type" to it.type.rawValue) } + ) + + // Collect products by ID (no duplicates possible in Play Billing) + fetched.forEach { product -> + byId.putIfAbsent(product.id, product) + } + } - listOf(ProductQueryType.InApp, ProductQueryType.Subs).forEach { kind -> + // Return products in the same order as input skusList + skusList.mapNotNull { byId[it] } + } + else -> { RnIapLog.payload( "fetchProducts.native", - mapOf("skus" to skusList, "type" to kind.rawValue) + mapOf("skus" to skusList, "type" to queryType.rawValue) ) - val fetched = openIap.fetchProducts(ProductRequest(skusList, kind)).productsOrEmpty() + val fetched = openIap.fetchProducts(ProductRequest(skusList, queryType)).productsOrEmpty() RnIapLog.result( "fetchProducts.native", fetched.map { mapOf("id" to it.id, "type" to it.type.rawValue) } ) - // Collect products by ID (no duplicates possible in Play Billing) - fetched.forEach { product -> - byId.putIfAbsent(product.id, product) - } + // Preserve input order for non-All queries + val byId = fetched.associateBy { it.id } + skusList.mapNotNull { byId[it] } } - - // Return products in the same order as input skusList - skusList.mapNotNull { byId[it] } - } - else -> { - RnIapLog.payload( - "fetchProducts.native", - mapOf("skus" to skusList, "type" to queryType.rawValue) - ) - val fetched = openIap.fetchProducts(ProductRequest(skusList, queryType)).productsOrEmpty() - RnIapLog.result( - "fetchProducts.native", - fetched.map { mapOf("id" to it.id, "type" to it.type.rawValue) } - ) - - // Preserve input order for non-All queries - val byId = fetched.associateBy { it.id } - skusList.mapNotNull { byId[it] } } + } catch (e: OpenIAPError) { + throw OpenIapException(toErrorJson(e)) } products.forEach { p -> productTypeBySku[p.id] = p.type.rawValue } @@ -1943,27 +1939,29 @@ class HybridRnIap : HybridRnIapSpec() { val message = messageOverride?.takeIf { it.isNotBlank() } ?: error.message?.takeIf { it.isNotBlank() } ?: OpenIAPError.Companion.defaultMessage(code) + val diagnostics = error.toJSON() + val responseCode = (diagnostics["responseCode"] as? Number)?.toInt() + val productIds = diagnostics["productIds"] as? List<*> + val productType = diagnostics["productType"] as? String + val isEmptyProductList = diagnostics["isEmptyProductList"] as? Boolean - val errorMap = mutableMapOf( + val errorMap = mutableMapOf( "code" to code, "message" to message ) - errorMap["responseCode"] = -1 - debugMessage?.let { errorMap["debugMessage"] = it } ?: error.message?.let { errorMap["debugMessage"] = it } + errorMap["responseCode"] = responseCode ?: -1 + debugMessage + ?.let { errorMap["debugMessage"] = it } + ?: (diagnostics["debugMessage"] as? String)?.let { errorMap["debugMessage"] = it } + ?: error.message?.let { errorMap["debugMessage"] = it } productId?.let { errorMap["productId"] = it } + if (!productIds.isNullOrEmpty()) errorMap["productIds"] = productIds + productType?.let { errorMap["productType"] = it } + isEmptyProductList?.let { errorMap["isEmptyProductList"] = it } return try { - val jsonPairs = errorMap.map { (key, value) -> - val valueStr = when (value) { - is String -> "\"${value.replace("\"", "\\\"")}\"" - is Number -> value.toString() - is Boolean -> value.toString() - else -> "\"$value\"" - } - "\"$key\":$valueStr" - } - "{${jsonPairs.joinToString(",")}}" + JSONObject(errorMap).toString() } catch (e: Exception) { "$code: $message" } @@ -2026,9 +2024,12 @@ class HybridRnIap : HybridRnIapSpec() { val message = messageOverride?.takeIf { it.isNotBlank() } ?: error.message?.takeIf { it.isNotBlank() } ?: OpenIAPError.Companion.defaultMessage(code) + val diagnostics = error.toJSON() + val responseCode = (diagnostics["responseCode"] as? Number)?.toDouble() + val diagnosticMessage = diagnostics["debugMessage"] as? String return NitroPurchaseResult( - responseCode = -1.0, - debugMessage = debugMessage ?: error.message, + responseCode = responseCode ?: -1.0, + debugMessage = debugMessage ?: diagnosticMessage ?: error.message, code = code, message = message, purchaseToken = null diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index c516128e..b0be229c 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -816,7 +816,18 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { return convertedProducts as FetchProductsResult; } catch (error) { RnIapConsole.error('[fetchProducts] Failed:', error); - throw error; + const parsedError = parseErrorStringToJsonObj(error); + throw createPurchaseError({ + code: parsedError.code, + message: parsedError.message, + responseCode: parsedError.responseCode, + debugMessage: parsedError.debugMessage, + productId: parsedError.productId, + productIds: parsedError.productIds, + productType: parsedError.productType, + isEmptyProductList: parsedError.isEmptyProductList, + platform: Platform.OS === 'ios' ? 'ios' : 'android', + }); } }; diff --git a/libraries/react-native-iap/src/utils/error.ts b/libraries/react-native-iap/src/utils/error.ts index 2969e705..c5abd361 100644 --- a/libraries/react-native-iap/src/utils/error.ts +++ b/libraries/react-native-iap/src/utils/error.ts @@ -10,6 +10,9 @@ export interface IapError { responseCode?: number; debugMessage?: string; productId?: string; + productIds?: string[]; + productType?: string; + isEmptyProductList?: boolean; [key: string]: any; // Allow additional platform-specific fields } diff --git a/libraries/react-native-iap/src/utils/errorMapping.ts b/libraries/react-native-iap/src/utils/errorMapping.ts index 9a93a6c7..aee6fbb3 100644 --- a/libraries/react-native-iap/src/utils/errorMapping.ts +++ b/libraries/react-native-iap/src/utils/errorMapping.ts @@ -51,6 +51,9 @@ export interface PurchaseErrorProps { debugMessage?: string; code?: ErrorCode | string | number; productId?: string; + productIds?: string[]; + productType?: string; + isEmptyProductList?: boolean; platform?: IapPlatform; } @@ -59,6 +62,9 @@ export interface PurchaseError extends Error { debugMessage?: string; code?: ErrorCode; productId?: string; + productIds?: string[]; + productType?: string; + isEmptyProductList?: boolean; platform?: IapPlatform; } @@ -137,6 +143,9 @@ export const createPurchaseError = ( error.debugMessage = props.debugMessage; error.code = errorCode; error.productId = props.productId; + error.productIds = props.productIds; + error.productType = props.productType; + error.isEmptyProductList = props.isEmptyProductList; error.platform = props.platform; return error; }; @@ -158,6 +167,9 @@ export const createPurchaseErrorFromPlatform = ( debugMessage: errorData.debugMessage, code: errorCode, productId: errorData.productId, + productIds: errorData.productIds, + productType: errorData.productType, + isEmptyProductList: errorData.isEmptyProductList, platform, }); }; diff --git a/packages/docs/src/pages/docs/errors.tsx b/packages/docs/src/pages/docs/errors.tsx index 3b696a6f..d6518423 100644 --- a/packages/docs/src/pages/docs/errors.tsx +++ b/packages/docs/src/pages/docs/errors.tsx @@ -198,6 +198,16 @@ var debug_message: Variant = null # Raw diagnostic from the billing layer`} +
+ Android diagnostics: QueryProduct errors + from openiap-google 2.1.4 and later include the Google + Play Billing responseCode, debugMessage, + requested productIds, requested productType, + and isEmptyProductList when available. Use these + diagnostics to distinguish Play Console product setup issues from + transient BillingClient failures. +
+

Network & Service Errors

diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 319242b4..6d50d2ec 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -123,11 +123,11 @@ function Releases() { // May 7, 2026 — non-Godot SDK parity + native API wiring guardrails { - id: 'apple-2-1-6-google-2-1-3-sdk-parity', + id: 'apple-2-1-6-google-2-1-4-sdk-parity', date: new Date('2026-05-07'), element: ( -
- +
+ May 7, 2026 — Non-Godot SDK parity patch releases @@ -168,12 +168,15 @@ function Releases() { to the unified method for backward compatibility.
  • - openiap-google 2.1.3 — wires Play and Horizon + openiap-google 2.1.4 — wires Play and Horizon handler bundles for getStorefront, legacy alternative billing helpers, and Billing Programs APIs such as{' '} isBillingProgramAvailableAndroid,{' '} launchExternalLinkAndroid, and{' '} - createBillingProgramReportingDetailsAndroid. + createBillingProgramReportingDetailsAndroid. This + release also improves Android QueryProduct failures + with Billing response code, debug message, queried product IDs, + product type, and empty-result diagnostics.
  • Example parity — Expo, React Native classic, @@ -236,11 +239,11 @@ function Releases() {
  • - openiap-google 2.1.3 + openiap-google 2.1.4
  • diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index f267b057..744b5240 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -574,7 +574,13 @@ class OpenIapModule( client.queryProductDetailsAsync(params) { billingResult, productDetailsList -> if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { - val err = OpenIapError.QueryProduct + val err = OpenIapError.QueryProduct( + responseCode = billingResult.responseCode, + debugMessage = billingResult.debugMessage, + productIds = missing, + productType = desiredType, + isEmptyProductList = productDetailsList.isNullOrEmpty() + ) purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } consumePurchaseCallback(Result.success(emptyList())) return@queryProductDetailsAsync diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt index 08d50ba2..9d51afb8 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt @@ -14,12 +14,20 @@ sealed class OpenIapError : Exception() { * the exact argument the store rejected. */ open val debugMessage: String? = null + open val responseCode: Int? = null + open val productIds: List = emptyList() + open val productType: String? = null + open val isEmptyProductList: Boolean? = null fun toJSON(): Map = mapOf( "code" to toCode(this), "message" to (this.message ?: ""), "platform" to "android", "debugMessage" to debugMessage, + "responseCode" to responseCode, + "productIds" to productIds, + "productType" to productType, + "isEmptyProductList" to isEmptyProductList, ) class ProductNotFound(val productId: String) : OpenIapError() { @@ -166,6 +174,31 @@ sealed class OpenIapError : Exception() { override val message = MESSAGE const val MESSAGE = "Failed to query product" + + operator fun invoke( + responseCode: Int? = null, + debugMessage: String? = null, + productIds: List = emptyList(), + productType: String? = null, + isEmptyProductList: Boolean? = null, + ): OpenIapError = QueryProductFailure( + responseCode = responseCode, + debugMessage = debugMessage, + productIds = productIds, + productType = productType, + isEmptyProductList = isEmptyProductList, + ) + } + + data class QueryProductFailure( + override val responseCode: Int? = null, + override val debugMessage: String? = null, + override val productIds: List = emptyList(), + override val productType: String? = null, + override val isEmptyProductList: Boolean? = null, + ) : OpenIapError() { + override val code: String = QueryProduct.CODE + override val message: String = QueryProduct.MESSAGE } object EmptySkuList : OpenIapError() { diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index d481832d..11cc0ee2 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -1139,7 +1139,13 @@ class OpenIapModule( } buildAndLaunch(ordered) } else { - val err = OpenIapError.QueryProduct + val err = OpenIapError.QueryProduct( + responseCode = billingResult.responseCode, + debugMessage = billingResult.debugMessage, + productIds = missing, + productType = desiredType, + isEmptyProductList = productDetailsList.isNullOrEmpty() + ) for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } consumePurchaseCallback(Result.success(emptyList())) } diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt index 45c54db9..85e323e4 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt @@ -5,7 +5,6 @@ import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.ProductDetails import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapLog -import dev.hyo.openiap.fromBillingResponseCode import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -102,9 +101,12 @@ internal class ProductManager { if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { cont.resumeWithException( - OpenIapError.fromBillingResponseCode( - billingResult.responseCode, - billingResult.debugMessage + OpenIapError.QueryProduct( + responseCode = billingResult.responseCode, + debugMessage = billingResult.debugMessage, + productIds = needsQuery.toList(), + productType = productType, + isEmptyProductList = result.productDetailsList.isNullOrEmpty() ) ) return@queryProductDetailsAsync diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt index 21b0332f..d49e97d4 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt @@ -115,6 +115,31 @@ class OpenIapErrorTest { assertEquals("Failed to query product", error.message) } + @Test + fun `QueryProduct carries billing diagnostics when provided`() { + val error = OpenIapError.QueryProduct( + responseCode = BillingClient.BillingResponseCode.DEVELOPER_ERROR, + debugMessage = "Invalid product ID", + productIds = listOf("premium_monthly", "lifetime"), + productType = BillingClient.ProductType.SUBS, + isEmptyProductList = true, + ) + val json = error.toJSON() + + assertEquals(ErrorCode.QueryProduct.rawValue, error.code) + assertEquals("Failed to query product", error.message) + assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, error.responseCode) + assertEquals("Invalid product ID", error.debugMessage) + assertEquals(listOf("premium_monthly", "lifetime"), error.productIds) + assertEquals(BillingClient.ProductType.SUBS, error.productType) + assertEquals(true, error.isEmptyProductList) + assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, json["responseCode"]) + assertEquals("Invalid product ID", json["debugMessage"]) + assertEquals(listOf("premium_monthly", "lifetime"), json["productIds"]) + assertEquals(BillingClient.ProductType.SUBS, json["productType"]) + assertEquals(true, json["isEmptyProductList"]) + } + @Test fun `EmptySkuList has correct code and message`() { val error = OpenIapError.EmptySkuList @@ -445,4 +470,4 @@ class OpenIapErrorTest { assertEquals("Both formats should parse to same ErrorCode", fromKebab, fromCamel) } } -} \ No newline at end of file +} From 5cecfeaab98c9bbc6b523ba3c9b049fa1f09ea3c Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 01:29:09 +0900 Subject: [PATCH 2/7] fix: address pr review feedback --- .github/workflows/release-maui.yml | 61 ++++-------- CLAUDE.md | 6 +- knowledge/internal/04-platform-packages.md | 5 +- knowledge/internal/06-git-deployment.md | 6 ++ libraries/maui-iap/CLAUDE.md | 2 +- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 40 ++++---- .../margelo/nitro/iap/ProductQueryHelpers.kt | 34 +++++++ .../nitro/iap/ProductQueryHelpersTest.kt | 99 +++++++++++++++++++ openiap-versions.json | 3 +- packages/docs/openiap-versions.json | 3 +- .../main/java/dev/hyo/openiap/OpenIapError.kt | 27 +++-- .../java/dev/hyo/openiap/OpenIapErrorTest.kt | 13 +++ packages/kit/convex/_generated/api.d.ts | 2 + 13 files changed, 216 insertions(+), 85 deletions(-) create mode 100644 libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt create mode 100644 libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt diff --git a/.github/workflows/release-maui.yml b/.github/workflows/release-maui.yml index fcd49fa2..84bcd904 100644 --- a/.github/workflows/release-maui.yml +++ b/.github/workflows/release-maui.yml @@ -125,7 +125,15 @@ jobs: - name: Calculate new version id: version run: | - CURRENT_VERSION=$(jq -r '.maui' openiap-versions.json) + CSPROJ=libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj + CURRENT_VERSION=$(sed -n -E 's|.*([^<]+).*|\1|p' "$CSPROJ" | head -n 1) + if [ -z "$CURRENT_VERSION" ]; then + CURRENT_VERSION=$(sed -n -E 's|.*([^<]+).*|\1|p' "$CSPROJ" | head -n 1) + fi + if [ -z "$CURRENT_VERSION" ]; then + echo "❌ Unable to read MAUI package version from $CSPROJ" + exit 1 + fi echo "Current version: $CURRENT_VERSION" VERSION_TYPE="${{ inputs.version }}" @@ -186,34 +194,22 @@ jobs: echo "✓ Tag maui-iap-$VERSION does not exist, proceeding with release" fi - - name: Update version in openiap-versions.json - if: steps.version.outputs.skip_version_commit != 'true' - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - jq --arg version "$VERSION" '.maui = $version' openiap-versions.json > openiap-versions.tmp - mv openiap-versions.tmp openiap-versions.json - echo "Updated openiap-versions.json:" - cat openiap-versions.json - - - name: Sync version files - if: steps.version.outputs.skip_version_commit != 'true' - run: ./scripts/sync-versions.sh - - - name: Update in OpenIap.Maui.csproj + - name: Update package version in OpenIap.Maui.csproj if: steps.version.outputs.skip_version_commit != 'true' env: VERSION: ${{ steps.version.outputs.version }} run: | CSPROJ=libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj - if grep -q "" "$CSPROJ"; then + if grep -q "" "$CSPROJ"; then + sed -i '' -E "s|[^<]*|$VERSION|" "$CSPROJ" + elif grep -q "" "$CSPROJ"; then sed -i '' -E "s|[^<]*|$VERSION|" "$CSPROJ" else - # Insert immediately after the first opening . + # Insert immediately after PackageId so package metadata stays grouped. awk -v ver="$VERSION" ' - !inserted && // { + !inserted && // { print - print " " ver "" + print " " ver "" inserted = 1 next } @@ -221,7 +217,7 @@ jobs: ' "$CSPROJ" > "$CSPROJ.tmp" mv "$CSPROJ.tmp" "$CSPROJ" fi - echo "Updated csproj to $VERSION" + echo "Updated csproj package version to $VERSION" - name: Commit version updates if: steps.version.outputs.skip_version_commit != 'true' @@ -231,7 +227,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add openiap-versions.json packages/*/openiap-versions.json libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj + git add libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj if git diff --staged --quiet; then echo "No version changes to commit" @@ -239,25 +235,8 @@ jobs: git commit -m "chore(maui): bump version to $VERSION" for i in 1 2 3; do if ! git pull --rebase origin main; then - echo "⚠️ Rebase conflict, auto-resolving openiap-versions.json" - for conflict_file in $(git diff --name-only --diff-filter=U); do - if [[ "$conflict_file" == *"openiap-versions.json" ]]; then - git show HEAD:"$conflict_file" > /tmp/theirs.json 2>/dev/null || true - if [ -s /tmp/theirs.json ]; then - jq --arg version "$VERSION" '.maui = $version' /tmp/theirs.json > /tmp/merged.json - cp /tmp/merged.json "$conflict_file" - else - git checkout --theirs "$conflict_file" 2>/dev/null || git checkout --ours "$conflict_file" - fi - git add "$conflict_file" - else - echo "❌ Unexpected conflict in $conflict_file" - exit 1 - fi - done - cp openiap-versions.json packages/docs/openiap-versions.json - git add packages/docs/openiap-versions.json - GIT_EDITOR=true git rebase --continue || { echo "❌ Rebase continue failed"; exit 1; } + echo "❌ Rebase conflict while updating MAUI package version" + exit 1 fi if git push origin main; then echo "✅ Pushed version update" diff --git a/CLAUDE.md b/CLAUDE.md index 687e5746..77c8e641 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,11 @@ openiap/ - `libraries/flutter_inapp_purchase/lib/types.dart` - Synced from GQL - `libraries/godot-iap/addons/godot-iap/types.gd` - Synced from GQL - `libraries/maui-iap/src/OpenIap.Maui/Types.cs` - Synced from GQL -- `openiap-versions.json` - Managed by CI/CD workflows only +- `openiap-versions.json` - Managed by CI/CD workflows only; tracks only `spec`, `google`, and `apple` + +Framework library package versions (React Native, Expo, Flutter, Godot, KMP, +MAUI) live in their own package metadata / release workflows. Do not add +framework-library version keys to `openiap-versions.json`. Regenerate and sync types: diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index c0630abe..0f3d2f88 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -29,8 +29,9 @@ Version is managed in `openiap-versions.json`: ```json { - "apple": "1.2.5", - "gql": "1.0.10" + "spec": "2.0.1", + "google": "2.1.3", + "apple": "2.1.6" } ``` diff --git a/knowledge/internal/06-git-deployment.md b/knowledge/internal/06-git-deployment.md index cf804ec8..52cdcf11 100644 --- a/knowledge/internal/06-git-deployment.md +++ b/knowledge/internal/06-git-deployment.md @@ -174,6 +174,12 @@ This file is automatically managed by CI/CD workflows during releases: - GQL releases update `spec` version - Deploy script (`npm run deploy`) updates `spec` version +The manifest is only for the shared spec and native platform packages: +`spec`, `google`, and `apple`. Framework library package versions +(`react-native-iap`, `expo-iap`, `flutter_inapp_purchase`, `godot-iap`, +`kmp-iap`, `maui-iap`) must stay in each library's own package metadata and +release workflow, not as extra keys in `openiap-versions.json`. + Manual edits will cause version conflicts and deployment issues. Always use the GitHub Actions workflows or deploy script to update versions. **Why this matters:** If a feature PR sets `apple: "2.1.1"` manually, and then CI auto-bumps on release, CI sees "current is 2.1.1" and bumps to 2.1.2 — skipping 2.1.1 entirely. The published tag becomes 2.1.2 with no 2.1.1 ever existing. diff --git a/libraries/maui-iap/CLAUDE.md b/libraries/maui-iap/CLAUDE.md index e39f4aa1..e35fce24 100644 --- a/libraries/maui-iap/CLAUDE.md +++ b/libraries/maui-iap/CLAUDE.md @@ -31,7 +31,7 @@ libraries/maui-iap/ ├── README.md — public-facing intro, install ├── CLAUDE.md — this file ├── CONVENTION.md — C# / MAUI conventions -├── openiap-versions.json — symlink to repo-root version manifest +├── openiap-versions.json — symlink for native spec/apple/google versions └── src/ └── OpenIap.Maui/ ├── OpenIap.Maui.csproj — multi-target (net9.0 + ios/android/maccatalyst) diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 3a000bbc..cdd37a8e 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -338,28 +338,24 @@ class HybridRnIap : HybridRnIapSpec() { val products: List = try { when (queryType) { ProductQueryType.All -> { - // Fetch both InApp and Subs products - val byId = mutableMapOf() - - listOf(ProductQueryType.InApp, ProductQueryType.Subs).forEach { kind -> - RnIapLog.payload( - "fetchProducts.native", - mapOf("skus" to skusList, "type" to kind.rawValue) - ) - val fetched = openIap.fetchProducts(ProductRequest(skusList, kind)).productsOrEmpty() - RnIapLog.result( - "fetchProducts.native", - fetched.map { mapOf("id" to it.id, "type" to it.type.rawValue) } - ) - - // Collect products by ID (no duplicates possible in Play Billing) - fetched.forEach { product -> - byId.putIfAbsent(product.id, product) - } - } - - // Return products in the same order as input skusList - skusList.mapNotNull { byId[it] } + collectAllQueryProducts( + skusList = skusList, + fetchKind = { kind -> + RnIapLog.payload( + "fetchProducts.native", + mapOf("skus" to skusList, "type" to kind.rawValue) + ) + val fetched = openIap.fetchProducts(ProductRequest(skusList, kind)).productsOrEmpty() + RnIapLog.result( + "fetchProducts.native", + fetched.map { mapOf("id" to it.id, "type" to it.type.rawValue) } + ) + fetched + }, + onFailure = { kind, error -> + RnIapLog.failure("fetchProducts.native[${kind.rawValue}]", error) + }, + ) } else -> { RnIapLog.payload( diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt new file mode 100644 index 00000000..3c5a2f6f --- /dev/null +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt @@ -0,0 +1,34 @@ +package com.margelo.nitro.iap + +import dev.hyo.openiap.ProductCommon +import dev.hyo.openiap.ProductQueryType +import kotlin.coroutines.cancellation.CancellationException + +internal suspend fun collectAllQueryProducts( + skusList: List, + fetchKind: suspend (ProductQueryType) -> List, + onFailure: (ProductQueryType, Throwable) -> Unit = { _, _ -> }, +): List { + val byId = linkedMapOf() + var firstFailure: Throwable? = null + + listOf(ProductQueryType.InApp, ProductQueryType.Subs).forEach { kind -> + runCatching { + fetchKind(kind) + }.onSuccess { fetched -> + fetched.forEach { product -> + byId.putIfAbsent(product.id, product) + } + }.onFailure { error -> + if (error is CancellationException) throw error + onFailure(kind, error) + if (firstFailure == null) firstFailure = error + } + } + + if (byId.isEmpty()) { + firstFailure?.let { throw it } + } + + return skusList.mapNotNull { byId[it] } +} diff --git a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt new file mode 100644 index 00000000..e57fac2f --- /dev/null +++ b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt @@ -0,0 +1,99 @@ +package com.margelo.nitro.iap + +import dev.hyo.openiap.IapPlatform +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.ProductCommon +import dev.hyo.openiap.ProductQueryType +import dev.hyo.openiap.ProductType +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class ProductQueryHelpersTest { + @Test + fun `all query returns partial success when one product kind fails`() = runBlocking { + val queryError = OpenIapError.BillingError("Invalid subscriptions") + val failures = mutableListOf>() + + val products = collectAllQueryProducts( + skusList = listOf("monthly", "lifetime"), + fetchKind = { kind -> + when (kind) { + ProductQueryType.InApp -> listOf(fakeProduct("lifetime", ProductType.InApp)) + ProductQueryType.Subs -> throw queryError + ProductQueryType.All -> error("All should be expanded by the helper") + } + }, + onFailure = { kind, error -> failures += kind to error }, + ) + + assertEquals(listOf("lifetime"), products.map { it.id }) + assertEquals(listOf(ProductQueryType.Subs), failures.map { it.first }) + assertSame(queryError, failures.single().second) + } + + @Test + fun `all query rethrows first failure when both product kinds fail`() = runBlocking { + val firstError = OpenIapError.BillingError("Invalid in-app products") + val secondError = OpenIapError.BillingError("Service unavailable") + val failures = mutableListOf() + + try { + collectAllQueryProducts( + skusList = listOf("monthly", "lifetime"), + fetchKind = { kind -> + when (kind) { + ProductQueryType.InApp -> throw firstError + ProductQueryType.Subs -> throw secondError + ProductQueryType.All -> error("All should be expanded by the helper") + } + }, + onFailure = { kind, _ -> failures += kind }, + ) + } catch (error: Throwable) { + assertSame(firstError, error) + assertEquals(listOf(ProductQueryType.InApp, ProductQueryType.Subs), failures) + return@runBlocking + } + + error("Expected the first product query failure to be rethrown") + } + + @Test + fun `all query preserves input sku order and keeps first matching product`() = runBlocking { + val products = collectAllQueryProducts( + skusList = listOf("monthly", "lifetime", "annual"), + fetchKind = { kind -> + when (kind) { + ProductQueryType.InApp -> listOf( + fakeProduct("lifetime", ProductType.InApp), + fakeProduct("monthly", ProductType.InApp), + ) + ProductQueryType.Subs -> listOf( + fakeProduct("monthly", ProductType.Subs), + fakeProduct("annual", ProductType.Subs), + ) + ProductQueryType.All -> error("All should be expanded by the helper") + } + }, + ) + + assertEquals(listOf("monthly", "lifetime", "annual"), products.map { it.id }) + assertEquals(ProductType.InApp, products[0].type) + } + + private fun fakeProduct(productId: String, type: ProductType): ProductCommon = + object : ProductCommon { + override val currency: String = "USD" + override val debugDescription: String? = null + override val description: String = productId + override val displayName: String? = productId + override val displayPrice: String = "$1.00" + override val id: String = productId + override val platform: IapPlatform = IapPlatform.Android + override val price: Double? = 1.0 + override val title: String = productId + override val type: ProductType = type + } +} diff --git a/openiap-versions.json b/openiap-versions.json index 6e9ecb30..d8ab0a82 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,6 +1,5 @@ { "spec": "2.0.1", "google": "2.1.3", - "apple": "2.1.6", - "maui": "1.0.0" + "apple": "2.1.6" } diff --git a/packages/docs/openiap-versions.json b/packages/docs/openiap-versions.json index 6e9ecb30..d8ab0a82 100644 --- a/packages/docs/openiap-versions.json +++ b/packages/docs/openiap-versions.json @@ -1,6 +1,5 @@ { "spec": "2.0.1", "google": "2.1.3", - "apple": "2.1.6", - "maui": "1.0.0" + "apple": "2.1.6" } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt index 9d51afb8..de951d70 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt @@ -14,20 +14,12 @@ sealed class OpenIapError : Exception() { * the exact argument the store rejected. */ open val debugMessage: String? = null - open val responseCode: Int? = null - open val productIds: List = emptyList() - open val productType: String? = null - open val isEmptyProductList: Boolean? = null - fun toJSON(): Map = mapOf( + open fun toJSON(): Map = mapOf( "code" to toCode(this), "message" to (this.message ?: ""), "platform" to "android", "debugMessage" to debugMessage, - "responseCode" to responseCode, - "productIds" to productIds, - "productType" to productType, - "isEmptyProductList" to isEmptyProductList, ) class ProductNotFound(val productId: String) : OpenIapError() { @@ -181,7 +173,7 @@ sealed class OpenIapError : Exception() { productIds: List = emptyList(), productType: String? = null, isEmptyProductList: Boolean? = null, - ): OpenIapError = QueryProductFailure( + ): QueryProductFailure = QueryProductFailure( responseCode = responseCode, debugMessage = debugMessage, productIds = productIds, @@ -191,14 +183,21 @@ sealed class OpenIapError : Exception() { } data class QueryProductFailure( - override val responseCode: Int? = null, + val responseCode: Int? = null, override val debugMessage: String? = null, - override val productIds: List = emptyList(), - override val productType: String? = null, - override val isEmptyProductList: Boolean? = null, + val productIds: List = emptyList(), + val productType: String? = null, + val isEmptyProductList: Boolean? = null, ) : OpenIapError() { override val code: String = QueryProduct.CODE override val message: String = QueryProduct.MESSAGE + + override fun toJSON(): Map = super.toJSON() + mapOf( + "responseCode" to responseCode, + "productIds" to productIds, + "productType" to productType, + "isEmptyProductList" to isEmptyProductList, + ) } object EmptySkuList : OpenIapError() { diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt index d49e97d4..fbade985 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt @@ -2,6 +2,7 @@ package dev.hyo.openiap import com.android.billingclient.api.BillingClient import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -140,6 +141,18 @@ class OpenIapErrorTest { assertEquals(true, json["isEmptyProductList"]) } + @Test + fun `non QueryProduct errors do not serialize query diagnostics`() { + val json = OpenIapError.PurchaseFailed("Billing failed").toJSON() + + assertEquals(ErrorCode.PurchaseError.rawValue, json["code"]) + assertEquals("Billing failed", json["debugMessage"]) + assertFalse(json.containsKey("responseCode")) + assertFalse(json.containsKey("productIds")) + assertFalse(json.containsKey("productType")) + assertFalse(json.containsKey("isEmptyProductList")) + } + @Test fun `EmptySkuList has correct code and message`() { val error = OpenIapError.EmptySkuList diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index d59d379f..cf45d8fb 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -70,6 +70,7 @@ import type * as userProfiles_query from "../userProfiles/query.js"; import type * as users_internal from "../users/internal.js"; import type * as users_query from "../users/query.js"; import type * as utils_concurrency from "../utils/concurrency.js"; +import type * as utils_currency from "../utils/currency.js"; import type * as utils_errors from "../utils/errors.js"; import type * as utils_helpers from "../utils/helpers.js"; import type * as utils_validation from "../utils/validation.js"; @@ -149,6 +150,7 @@ declare const fullApi: ApiFromModules<{ "users/internal": typeof users_internal; "users/query": typeof users_query; "utils/concurrency": typeof utils_concurrency; + "utils/currency": typeof utils_currency; "utils/errors": typeof utils_errors; "utils/helpers": typeof utils_helpers; "utils/validation": typeof utils_validation; From d56c2ab0af1ab5822c6e5fed83e6ab73fc41b1ad Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 01:44:20 +0900 Subject: [PATCH 3/7] fix(rn): parallelize all product queries --- .../margelo/nitro/iap/ProductQueryHelpers.kt | 20 ++++++--- .../nitro/iap/ProductQueryHelpersTest.kt | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt index 3c5a2f6f..bc683946 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt @@ -3,19 +3,27 @@ package com.margelo.nitro.iap import dev.hyo.openiap.ProductCommon import dev.hyo.openiap.ProductQueryType import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope internal suspend fun collectAllQueryProducts( skusList: List, fetchKind: suspend (ProductQueryType) -> List, onFailure: (ProductQueryType, Throwable) -> Unit = { _, _ -> }, -): List { +): List = coroutineScope { val byId = linkedMapOf() var firstFailure: Throwable? = null - listOf(ProductQueryType.InApp, ProductQueryType.Subs).forEach { kind -> - runCatching { - fetchKind(kind) - }.onSuccess { fetched -> + val queries = listOf(ProductQueryType.InApp, ProductQueryType.Subs).map { kind -> + kind to async { + runCatching { + fetchKind(kind) + } + } + } + + queries.forEach { (kind, query) -> + query.await().onSuccess { fetched -> fetched.forEach { product -> byId.putIfAbsent(product.id, product) } @@ -30,5 +38,5 @@ internal suspend fun collectAllQueryProducts( firstFailure?.let { throw it } } - return skusList.mapNotNull { byId[it] } + skusList.mapNotNull { byId[it] } } diff --git a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt index e57fac2f..450d6009 100644 --- a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt +++ b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt @@ -5,12 +5,53 @@ import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.ProductCommon import dev.hyo.openiap.ProductQueryType import dev.hyo.openiap.ProductType +import java.util.Collections +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertSame import org.junit.Test class ProductQueryHelpersTest { + @Test + fun `all query starts in-app and subs fetches concurrently`() = runBlocking { + val startedKinds = Collections.synchronizedSet(mutableSetOf()) + val bothStarted = CompletableDeferred() + + val products = withTimeout(1000) { + collectAllQueryProducts( + skusList = listOf("monthly", "lifetime"), + fetchKind = { kind -> + when (kind) { + ProductQueryType.InApp, + ProductQueryType.Subs -> { + startedKinds.add(kind) + if (startedKinds.size == 2) { + bothStarted.complete(Unit) + } + bothStarted.await() + + when (kind) { + ProductQueryType.InApp -> listOf( + fakeProduct("lifetime", ProductType.InApp), + ) + ProductQueryType.Subs -> listOf( + fakeProduct("monthly", ProductType.Subs), + ) + ProductQueryType.All -> error("All should be expanded by the helper") + } + } + ProductQueryType.All -> error("All should be expanded by the helper") + } + }, + ) + } + + assertEquals(setOf(ProductQueryType.InApp, ProductQueryType.Subs), startedKinds.toSet()) + assertEquals(listOf("monthly", "lifetime"), products.map { it.id }) + } + @Test fun `all query returns partial success when one product kind fails`() = runBlocking { val queryError = OpenIapError.BillingError("Invalid subscriptions") From d4aeac950dca02888a14735d10c84f2d05d7dac5 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 01:52:01 +0900 Subject: [PATCH 4/7] fix(gql): expose query product diagnostics --- libraries/expo-iap/src/types.ts | 4 ++ .../flutter_inapp_purchase/lib/types.dart | 16 +++++++ libraries/godot-iap/addons/godot-iap/types.gd | 19 ++++++++ .../io/github/hyochan/kmpiap/openiap/Types.kt | 14 +++++- libraries/maui-iap/src/OpenIap.Maui/Types.cs | 8 ++++ libraries/react-native-iap/src/types.ts | 4 ++ packages/apple/Sources/Models/Types.swift | 4 ++ packages/docs/src/pages/docs/errors.tsx | 47 +++++++++++++++---- .../src/main/java/dev/hyo/openiap/Types.kt | 14 +++++- packages/gql/src/error.graphql | 6 +++ .../gql/src/generated-purchase-error.test.ts | 35 ++++++++++++++ packages/gql/src/generated/Types.cs | 8 ++++ packages/gql/src/generated/Types.kt | 14 +++++- packages/gql/src/generated/Types.swift | 4 ++ packages/gql/src/generated/types.dart | 16 +++++++ packages/gql/src/generated/types.gd | 19 ++++++++ packages/gql/src/generated/types.ts | 4 ++ 17 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 packages/gql/src/generated-purchase-error.test.ts diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 2647f51b..7eb00860 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1215,8 +1215,12 @@ export interface PurchaseCommon { export interface PurchaseError { code: ErrorCode; debugMessage?: (string | null); + isEmptyProductList?: (boolean | null); message: string; productId?: (string | null); + productIds?: (string[] | null); + productType?: (string | null); + responseCode?: (number | null); } export interface PurchaseIOS extends PurchaseCommon { diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 210dd596..93997a8c 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -2961,21 +2961,33 @@ class PurchaseError { const PurchaseError({ required this.code, this.debugMessage, + this.isEmptyProductList, required this.message, this.productId, + this.productIds, + this.productType, + this.responseCode, }); final ErrorCode code; final String? debugMessage; + final bool? isEmptyProductList; final String message; final String? productId; + final List? productIds; + final String? productType; + final int? responseCode; factory PurchaseError.fromJson(Map json) { return PurchaseError( code: ErrorCode.fromJson(json['code'] as String), debugMessage: json['debugMessage'] as String?, + isEmptyProductList: json['isEmptyProductList'] as bool?, message: json['message'] as String, productId: json['productId'] as String?, + productIds: (json['productIds'] as List?) == null ? null : (json['productIds'] as List?)!.map((e) => e as String).toList(), + productType: json['productType'] as String?, + responseCode: json['responseCode'] as int?, ); } @@ -2984,8 +2996,12 @@ class PurchaseError { '__typename': 'PurchaseError', 'code': code.toJson(), 'debugMessage': debugMessage, + 'isEmptyProductList': isEmptyProductList, 'message': message, 'productId': productId, + 'productIds': productIds, + 'productType': productType, + 'responseCode': responseCode, }; } } diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 416cd5fb..29d37286 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -2287,6 +2287,10 @@ class PurchaseError: var message: String = "" var product_id: Variant = null var debug_message: Variant = null + var response_code: Variant = null + var product_ids: Array[String] = [] + var product_type: Variant = null + var is_empty_product_list: Variant = null static func from_dict(data: Dictionary) -> PurchaseError: var obj = PurchaseError.new() @@ -2302,6 +2306,14 @@ class PurchaseError: obj.product_id = data["productId"] if data.has("debugMessage") and data["debugMessage"] != null: obj.debug_message = data["debugMessage"] + if data.has("responseCode") and data["responseCode"] != null: + obj.response_code = data["responseCode"] + if data.has("productIds") and data["productIds"] != null: + obj.product_ids = data["productIds"] + if data.has("productType") and data["productType"] != null: + obj.product_type = data["productType"] + if data.has("isEmptyProductList") and data["isEmptyProductList"] != null: + obj.is_empty_product_list = data["isEmptyProductList"] return obj func to_dict() -> Dictionary: @@ -2315,6 +2327,13 @@ class PurchaseError: dict["productId"] = product_id if debug_message != null: dict["debugMessage"] = debug_message + if response_code != null: + dict["responseCode"] = response_code + dict["productIds"] = product_ids + if product_type != null: + dict["productType"] = product_type + if is_empty_product_list != null: + dict["isEmptyProductList"] = is_empty_product_list return dict class PurchaseIOS: diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index b63e3a5b..9f4afca3 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -3074,8 +3074,12 @@ public data class PurchaseAndroid( public data class PurchaseError( val code: ErrorCode, val debugMessage: String? = null, + val isEmptyProductList: Boolean? = null, val message: String, - val productId: String? = null + val productId: String? = null, + val productIds: List? = null, + val productType: String? = null, + val responseCode: Int? = null ) { companion object { @@ -3083,8 +3087,12 @@ public data class PurchaseError( return PurchaseError( code = (json["code"] as? String)?.let { ErrorCode.fromJson(it) } ?: ErrorCode.Unknown, debugMessage = json["debugMessage"] as? String, + isEmptyProductList = json["isEmptyProductList"] as? Boolean, message = json["message"] as? String ?: "", productId = json["productId"] as? String, + productIds = (json["productIds"] as? List<*>)?.mapNotNull { it as? String }, + productType = json["productType"] as? String, + responseCode = (json["responseCode"] as? Number)?.toInt(), ) } } @@ -3093,8 +3101,12 @@ public data class PurchaseError( "__typename" to "PurchaseError", "code" to code.toJson(), "debugMessage" to debugMessage, + "isEmptyProductList" to isEmptyProductList, "message" to message, "productId" to productId, + "productIds" to productIds, + "productType" to productType, + "responseCode" to responseCode, ) } diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index 45069b86..3a44df54 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -3121,10 +3121,18 @@ public sealed record PurchaseError public required ErrorCode Code { get; init; } [JsonPropertyName("debugMessage")] public string? DebugMessage { get; init; } + [JsonPropertyName("isEmptyProductList")] + public bool? IsEmptyProductList { get; init; } [JsonPropertyName("message")] public required string Message { get; init; } [JsonPropertyName("productId")] public string? ProductId { get; init; } + [JsonPropertyName("productIds")] + public IReadOnlyList? ProductIds { get; init; } + [JsonPropertyName("productType")] + public string? ProductType { get; init; } + [JsonPropertyName("responseCode")] + public int? ResponseCode { get; init; } } public sealed record PurchaseIOS : Purchase, PurchaseCommon diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 2647f51b..7eb00860 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1215,8 +1215,12 @@ export interface PurchaseCommon { export interface PurchaseError { code: ErrorCode; debugMessage?: (string | null); + isEmptyProductList?: (boolean | null); message: string; productId?: (string | null); + productIds?: (string[] | null); + productType?: (string | null); + responseCode?: (number | null); } export interface PurchaseIOS extends PurchaseCommon { diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 79115757..4b6a09da 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -1143,8 +1143,12 @@ public struct PurchaseAndroid: Codable, PurchaseCommon { public struct PurchaseError: Codable { public var code: ErrorCode public var debugMessage: String? = nil + public var isEmptyProductList: Bool? = nil public var message: String public var productId: String? = nil + public var productIds: [String]? = nil + public var productType: String? = nil + public var responseCode: Int? = nil } public struct PurchaseIOS: Codable, PurchaseCommon { diff --git a/packages/docs/src/pages/docs/errors.tsx b/packages/docs/src/pages/docs/errors.tsx index d6518423..77255b99 100644 --- a/packages/docs/src/pages/docs/errors.tsx +++ b/packages/docs/src/pages/docs/errors.tsx @@ -32,6 +32,10 @@ function Errors() { message: string; // Human-readable message productId?: string; // Related product SKU (if applicable) debugMessage?: string; // Raw diagnostic from the billing layer (e.g. Play's BillingResult.debugMessage) + responseCode?: number; // Android QueryProduct BillingResult.responseCode + productIds?: string[]; // Android QueryProduct requested IDs + productType?: string; // Android BillingClient product type + isEmptyProductList?: boolean; // Android QueryProduct returned no products }`} ), swift: ( @@ -40,6 +44,10 @@ function Errors() { let message: String // Human-readable message let productId: String? // Related product SKU (if applicable) let debugMessage: String? // Raw diagnostic (e.g. StoreKit error.localizedDescription) + let responseCode: Int? // Android QueryProduct BillingResult.responseCode + let productIds: [String]? // Android QueryProduct requested IDs + let productType: String? // Android BillingClient product type + let isEmptyProductList: Bool? // Android QueryProduct returned no products }`} ), kotlin: ( @@ -47,7 +55,11 @@ function Errors() { val code: String, // Error code constant val message: String, // Human-readable message val productId: String? = null, // Related product SKU (if applicable) - val debugMessage: String? = null // Raw BillingResult.debugMessage from Google Play + val debugMessage: String? = null, // Raw BillingResult.debugMessage from Google Play + val responseCode: Int? = null, // Android QueryProduct BillingResult.responseCode + val productIds: List? = null, // Android QueryProduct requested IDs + val productType: String? = null, // Android BillingClient product type + val isEmptyProductList: Boolean? = null // Android returned no products )`} ), kmp: ( @@ -55,7 +67,11 @@ function Errors() { val code: String, // Error code constant val message: String, // Human-readable message val productId: String? = null, // Related product SKU (if applicable) - val debugMessage: String? = null // Raw diagnostic from the underlying billing layer + val debugMessage: String? = null, // Raw diagnostic from the underlying billing layer + val responseCode: Int? = null, // Android QueryProduct BillingResult.responseCode + val productIds: List? = null, // Android QueryProduct requested IDs + val productType: String? = null, // Android BillingClient product type + val isEmptyProductList: Boolean? = null // Android returned no products )`} ), dart: ( @@ -64,18 +80,27 @@ function Errors() { final String message; // Human-readable message final String? productId; // Related product SKU (if applicable) final String? debugMessage; // Raw BillingResult.debugMessage on Android + final int? responseCode; // Android QueryProduct BillingResult.responseCode + final List? productIds; // Android QueryProduct requested IDs + final String? productType; // Android BillingClient product type + final bool? isEmptyProductList; // Android returned no products }`} ), csharp: ( {`using Hyo.OpenIap; using OpenIap.Maui; -data class PurchaseError( - var code: String, // Error code constant - var message: String, // Human-readable message - var productId = null, // Related product SKU (if applicable) - var debugMessage = null // Raw BillingResult.debugMessage from Google Play -)`} +public sealed record PurchaseError +{ + public required ErrorCode Code { get; init; } // Error code constant + public required string Message { get; init; } // Human-readable message + public string? ProductId { get; init; } // Related product SKU + public string? DebugMessage { get; init; } // Raw billing diagnostic + public int? ResponseCode { get; init; } // Android QueryProduct responseCode + public IReadOnlyList? ProductIds { get; init; } // Android requested IDs + public string? ProductType { get; init; } // Android BillingClient product type + public bool? IsEmptyProductList { get; init; } // Android returned no products +}`} ), gdscript: ( {`class_name PurchaseError @@ -83,7 +108,11 @@ data class PurchaseError( var code: String # Error code constant var message: String # Human-readable message var product_id: Variant = null # Related product SKU (if applicable) -var debug_message: Variant = null # Raw diagnostic from the billing layer`} +var debug_message: Variant = null # Raw diagnostic from the billing layer +var response_code: Variant = null # Android QueryProduct BillingResult.responseCode +var product_ids: Variant = null # Android QueryProduct requested IDs +var product_type: Variant = null # Android BillingClient product type +var is_empty_product_list: Variant = null # Android returned no products`} ), }} diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index de782cf0..edc23e07 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -2953,8 +2953,12 @@ public data class PurchaseAndroid( public data class PurchaseError( val code: ErrorCode, val debugMessage: String? = null, + val isEmptyProductList: Boolean? = null, val message: String, - val productId: String? = null + val productId: String? = null, + val productIds: List? = null, + val productType: String? = null, + val responseCode: Int? = null ) { companion object { @@ -2962,8 +2966,12 @@ public data class PurchaseError( return PurchaseError( code = (json["code"] as? String)?.let { ErrorCode.fromJson(it) } ?: ErrorCode.Unknown, debugMessage = json["debugMessage"] as? String, + isEmptyProductList = json["isEmptyProductList"] as? Boolean, message = json["message"] as? String ?: "", productId = json["productId"] as? String, + productIds = (json["productIds"] as? List<*>)?.mapNotNull { it as? String }, + productType = json["productType"] as? String, + responseCode = (json["responseCode"] as? Number)?.toInt(), ) } } @@ -2972,8 +2980,12 @@ public data class PurchaseError( "__typename" to "PurchaseError", "code" to code.toJson(), "debugMessage" to debugMessage, + "isEmptyProductList" to isEmptyProductList, "message" to message, "productId" to productId, + "productIds" to productIds, + "productType" to productType, + "responseCode" to responseCode, ) } diff --git a/packages/gql/src/error.graphql b/packages/gql/src/error.graphql index 5cec9ca2..04869d8d 100644 --- a/packages/gql/src/error.graphql +++ b/packages/gql/src/error.graphql @@ -55,4 +55,10 @@ type PurchaseError { # On Android this mirrors BillingResult.debugMessage; on iOS this is the # StoreKit error's localizedDescription (or equivalent). May be null. debugMessage: String + # Android QueryProduct diagnostics. Present for openiap-google QueryProduct + # failures when the native billing layer exposes the values. + responseCode: Int + productIds: [String!] + productType: String + isEmptyProductList: Boolean } diff --git a/packages/gql/src/generated-purchase-error.test.ts b/packages/gql/src/generated-purchase-error.test.ts new file mode 100644 index 00000000..38258e2a --- /dev/null +++ b/packages/gql/src/generated-purchase-error.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const generatedDir = resolve(currentDir, "generated"); + +function readGenerated(fileName: string): string { + return readFileSync(resolve(generatedDir, fileName), "utf8"); +} + +describe("generated PurchaseError diagnostics", () => { + it("keeps Android QueryProduct diagnostics in generated TypeScript types", () => { + const source = readGenerated("types.ts"); + + expect(source).toContain("responseCode?: (number | null);"); + expect(source).toContain("productIds?: (string[] | null);"); + expect(source).toContain("productType?: (string | null);"); + expect(source).toContain("isEmptyProductList?: (boolean | null);"); + }); + + it("keeps Android QueryProduct diagnostics in generated C# types", () => { + const source = readGenerated("Types.cs"); + + expect(source).toContain('[JsonPropertyName("responseCode")]'); + expect(source).toContain("public int? ResponseCode { get; init; }"); + expect(source).toContain('[JsonPropertyName("productIds")]'); + expect(source).toContain("public IReadOnlyList? ProductIds { get; init; }"); + expect(source).toContain('[JsonPropertyName("productType")]'); + expect(source).toContain("public string? ProductType { get; init; }"); + expect(source).toContain('[JsonPropertyName("isEmptyProductList")]'); + expect(source).toContain("public bool? IsEmptyProductList { get; init; }"); + }); +}); diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs index 45069b86..3a44df54 100644 --- a/packages/gql/src/generated/Types.cs +++ b/packages/gql/src/generated/Types.cs @@ -3121,10 +3121,18 @@ public sealed record PurchaseError public required ErrorCode Code { get; init; } [JsonPropertyName("debugMessage")] public string? DebugMessage { get; init; } + [JsonPropertyName("isEmptyProductList")] + public bool? IsEmptyProductList { get; init; } [JsonPropertyName("message")] public required string Message { get; init; } [JsonPropertyName("productId")] public string? ProductId { get; init; } + [JsonPropertyName("productIds")] + public IReadOnlyList? ProductIds { get; init; } + [JsonPropertyName("productType")] + public string? ProductType { get; init; } + [JsonPropertyName("responseCode")] + public int? ResponseCode { get; init; } } public sealed record PurchaseIOS : Purchase, PurchaseCommon diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 0d073c5d..b393cf86 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -3072,8 +3072,12 @@ public data class PurchaseAndroid( public data class PurchaseError( val code: ErrorCode, val debugMessage: String? = null, + val isEmptyProductList: Boolean? = null, val message: String, - val productId: String? = null + val productId: String? = null, + val productIds: List? = null, + val productType: String? = null, + val responseCode: Int? = null ) { companion object { @@ -3081,8 +3085,12 @@ public data class PurchaseError( return PurchaseError( code = (json["code"] as? String)?.let { ErrorCode.fromJson(it) } ?: ErrorCode.Unknown, debugMessage = json["debugMessage"] as? String, + isEmptyProductList = json["isEmptyProductList"] as? Boolean, message = json["message"] as? String ?: "", productId = json["productId"] as? String, + productIds = (json["productIds"] as? List<*>)?.mapNotNull { it as? String }, + productType = json["productType"] as? String, + responseCode = (json["responseCode"] as? Number)?.toInt(), ) } } @@ -3091,8 +3099,12 @@ public data class PurchaseError( "__typename" to "PurchaseError", "code" to code.toJson(), "debugMessage" to debugMessage, + "isEmptyProductList" to isEmptyProductList, "message" to message, "productId" to productId, + "productIds" to productIds, + "productType" to productType, + "responseCode" to responseCode, ) } diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 79115757..4b6a09da 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -1143,8 +1143,12 @@ public struct PurchaseAndroid: Codable, PurchaseCommon { public struct PurchaseError: Codable { public var code: ErrorCode public var debugMessage: String? = nil + public var isEmptyProductList: Bool? = nil public var message: String public var productId: String? = nil + public var productIds: [String]? = nil + public var productType: String? = nil + public var responseCode: Int? = nil } public struct PurchaseIOS: Codable, PurchaseCommon { diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 210dd596..93997a8c 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -2961,21 +2961,33 @@ class PurchaseError { const PurchaseError({ required this.code, this.debugMessage, + this.isEmptyProductList, required this.message, this.productId, + this.productIds, + this.productType, + this.responseCode, }); final ErrorCode code; final String? debugMessage; + final bool? isEmptyProductList; final String message; final String? productId; + final List? productIds; + final String? productType; + final int? responseCode; factory PurchaseError.fromJson(Map json) { return PurchaseError( code: ErrorCode.fromJson(json['code'] as String), debugMessage: json['debugMessage'] as String?, + isEmptyProductList: json['isEmptyProductList'] as bool?, message: json['message'] as String, productId: json['productId'] as String?, + productIds: (json['productIds'] as List?) == null ? null : (json['productIds'] as List?)!.map((e) => e as String).toList(), + productType: json['productType'] as String?, + responseCode: json['responseCode'] as int?, ); } @@ -2984,8 +2996,12 @@ class PurchaseError { '__typename': 'PurchaseError', 'code': code.toJson(), 'debugMessage': debugMessage, + 'isEmptyProductList': isEmptyProductList, 'message': message, 'productId': productId, + 'productIds': productIds, + 'productType': productType, + 'responseCode': responseCode, }; } } diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 416cd5fb..29d37286 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -2287,6 +2287,10 @@ class PurchaseError: var message: String = "" var product_id: Variant = null var debug_message: Variant = null + var response_code: Variant = null + var product_ids: Array[String] = [] + var product_type: Variant = null + var is_empty_product_list: Variant = null static func from_dict(data: Dictionary) -> PurchaseError: var obj = PurchaseError.new() @@ -2302,6 +2306,14 @@ class PurchaseError: obj.product_id = data["productId"] if data.has("debugMessage") and data["debugMessage"] != null: obj.debug_message = data["debugMessage"] + if data.has("responseCode") and data["responseCode"] != null: + obj.response_code = data["responseCode"] + if data.has("productIds") and data["productIds"] != null: + obj.product_ids = data["productIds"] + if data.has("productType") and data["productType"] != null: + obj.product_type = data["productType"] + if data.has("isEmptyProductList") and data["isEmptyProductList"] != null: + obj.is_empty_product_list = data["isEmptyProductList"] return obj func to_dict() -> Dictionary: @@ -2315,6 +2327,13 @@ class PurchaseError: dict["productId"] = product_id if debug_message != null: dict["debugMessage"] = debug_message + if response_code != null: + dict["responseCode"] = response_code + dict["productIds"] = product_ids + if product_type != null: + dict["productType"] = product_type + if is_empty_product_list != null: + dict["isEmptyProductList"] = is_empty_product_list return dict class PurchaseIOS: diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 2647f51b..7eb00860 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1215,8 +1215,12 @@ export interface PurchaseCommon { export interface PurchaseError { code: ErrorCode; debugMessage?: (string | null); + isEmptyProductList?: (boolean | null); message: string; productId?: (string | null); + productIds?: (string[] | null); + productType?: (string | null); + responseCode?: (number | null); } export interface PurchaseIOS extends PurchaseCommon { From eacdf4ecd1cd1d0d944afb7e2c76d6fc93d00219 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 01:58:09 +0900 Subject: [PATCH 5/7] fix(google): preserve query product singleton --- .../java/dev/hyo/openiap/OpenIapModule.kt | 2 +- .../main/java/dev/hyo/openiap/OpenIapError.kt | 71 ++++++++++++------- .../java/dev/hyo/openiap/OpenIapModule.kt | 2 +- .../dev/hyo/openiap/helpers/ProductManager.kt | 2 +- .../java/dev/hyo/openiap/OpenIapErrorTest.kt | 4 +- 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index 744b5240..56778879 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -574,7 +574,7 @@ class OpenIapModule( client.queryProductDetailsAsync(params) { billingResult, productDetailsList -> if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { - val err = OpenIapError.QueryProduct( + val err = OpenIapError.QueryProduct.withDiagnostics( responseCode = billingResult.responseCode, debugMessage = billingResult.debugMessage, productIds = missing, diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt index de951d70..8c306893 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt @@ -161,43 +161,64 @@ sealed class OpenIapError : Exception() { } object QueryProduct : OpenIapError() { + private data class Diagnostics( + val responseCode: Int? = null, + val debugMessage: String? = null, + val productIds: List = emptyList(), + val productType: String? = null, + val isEmptyProductList: Boolean? = null, + ) + + // Keep QueryProduct as the existing singleton for patch-level API compatibility. + @Volatile + private var diagnostics: Diagnostics? = null + val CODE = ErrorCode.QueryProduct.rawValue override val code = CODE override val message = MESSAGE + override val debugMessage: String? + get() = diagnostics?.debugMessage + + val responseCode: Int? + get() = diagnostics?.responseCode + + val productIds: List + get() = diagnostics?.productIds ?: emptyList() + + val productType: String? + get() = diagnostics?.productType + + val isEmptyProductList: Boolean? + get() = diagnostics?.isEmptyProductList const val MESSAGE = "Failed to query product" - operator fun invoke( + fun withDiagnostics( responseCode: Int? = null, debugMessage: String? = null, productIds: List = emptyList(), productType: String? = null, isEmptyProductList: Boolean? = null, - ): QueryProductFailure = QueryProductFailure( - responseCode = responseCode, - debugMessage = debugMessage, - productIds = productIds, - productType = productType, - isEmptyProductList = isEmptyProductList, - ) - } + ): QueryProduct { + diagnostics = Diagnostics( + responseCode = responseCode, + debugMessage = debugMessage, + productIds = productIds, + productType = productType, + isEmptyProductList = isEmptyProductList, + ) + return this + } - data class QueryProductFailure( - val responseCode: Int? = null, - override val debugMessage: String? = null, - val productIds: List = emptyList(), - val productType: String? = null, - val isEmptyProductList: Boolean? = null, - ) : OpenIapError() { - override val code: String = QueryProduct.CODE - override val message: String = QueryProduct.MESSAGE - - override fun toJSON(): Map = super.toJSON() + mapOf( - "responseCode" to responseCode, - "productIds" to productIds, - "productType" to productType, - "isEmptyProductList" to isEmptyProductList, - ) + override fun toJSON(): Map { + val current = diagnostics ?: return super.toJSON() + return super.toJSON() + mapOf( + "responseCode" to current.responseCode, + "productIds" to current.productIds, + "productType" to current.productType, + "isEmptyProductList" to current.isEmptyProductList, + ) + } } object EmptySkuList : OpenIapError() { diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 11cc0ee2..83d7685d 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -1139,7 +1139,7 @@ class OpenIapModule( } buildAndLaunch(ordered) } else { - val err = OpenIapError.QueryProduct( + val err = OpenIapError.QueryProduct.withDiagnostics( responseCode = billingResult.responseCode, debugMessage = billingResult.debugMessage, productIds = missing, diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt index 85e323e4..51a70cd3 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt @@ -101,7 +101,7 @@ internal class ProductManager { if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { cont.resumeWithException( - OpenIapError.QueryProduct( + OpenIapError.QueryProduct.withDiagnostics( responseCode = billingResult.responseCode, debugMessage = billingResult.debugMessage, productIds = needsQuery.toList(), diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt index fbade985..85d24f0f 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt @@ -3,6 +3,7 @@ package dev.hyo.openiap import com.android.billingclient.api.BillingClient import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -118,7 +119,7 @@ class OpenIapErrorTest { @Test fun `QueryProduct carries billing diagnostics when provided`() { - val error = OpenIapError.QueryProduct( + val error = OpenIapError.QueryProduct.withDiagnostics( responseCode = BillingClient.BillingResponseCode.DEVELOPER_ERROR, debugMessage = "Invalid product ID", productIds = listOf("premium_monthly", "lifetime"), @@ -127,6 +128,7 @@ class OpenIapErrorTest { ) val json = error.toJSON() + assertSame(OpenIapError.QueryProduct, error) assertEquals(ErrorCode.QueryProduct.rawValue, error.code) assertEquals("Failed to query product", error.message) assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, error.responseCode) From d7dba07aed2f055df7d8cf6a3ab9559e4c7c8c83 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 02:00:02 +0900 Subject: [PATCH 6/7] fix(rn): align openiap error reference --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 122 +++++++++--------- packages/docs/src/pages/docs/errors.tsx | 2 +- 2 files changed, 62 insertions(+), 62 deletions(-) diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index cdd37a8e..4b960d8b 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -10,7 +10,7 @@ import dev.hyo.openiap.FetchProductsResult import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions -import dev.hyo.openiap.OpenIapError as OpenIAPError +import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapModule import dev.hyo.openiap.ProductAndroid import dev.hyo.openiap.ProductQueryType @@ -134,7 +134,7 @@ class HybridRnIap : HybridRnIapSpec() { } catch (err: CancellationException) { throw err } catch (err: Throwable) { - val error = OpenIAPError.InitConnection + val error = OpenIapError.InitConnection val errorMessage = err.message ?: err.javaClass.name RnIapLog.failure("initConnection.setActivity", err) throw OpenIapException( @@ -173,8 +173,8 @@ class HybridRnIap : HybridRnIapSpec() { }.onFailure { RnIapLog.failure("purchaseUpdatedListener", it) } }) openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e -> - val code = OpenIAPError.toCode(e) - val message = e.message ?: OpenIAPError.defaultMessage(code) + val code = OpenIapError.toCode(e) + val message = e.message ?: OpenIapError.defaultMessage(code) runCatching { RnIapLog.result( "purchaseErrorListener", @@ -215,7 +215,7 @@ class HybridRnIap : HybridRnIapSpec() { throw err } catch (err: Throwable) { listenersAttached = false - val error = OpenIAPError.InitConnection + val error = OpenIapError.InitConnection val errorMessage = err.message ?: err.javaClass.name RnIapLog.failure("initConnection.listeners", err) val wrapped = OpenIapException( @@ -259,7 +259,7 @@ class HybridRnIap : HybridRnIapSpec() { openIap.initConnection(openIapConfig) } } catch (err: Throwable) { - val error = OpenIAPError.InitConnection + val error = OpenIapError.InitConnection RnIapLog.failure("initConnection.native", err) throw OpenIapException( toErrorJson( @@ -270,7 +270,7 @@ class HybridRnIap : HybridRnIapSpec() { ) } if (!ok) { - val error = OpenIAPError.InitConnection + val error = OpenIapError.InitConnection RnIapLog.failure("initConnection.native", Exception(error.message)) throw OpenIapException( toErrorJson( @@ -327,7 +327,7 @@ class HybridRnIap : HybridRnIapSpec() { ) if (skus.isEmpty()) { - throw OpenIapException(toErrorJson(OpenIAPError.EmptySkuList)) + throw OpenIapException(toErrorJson(OpenIapError.EmptySkuList)) } ensureConnection() @@ -373,7 +373,7 @@ class HybridRnIap : HybridRnIapSpec() { skusList.mapNotNull { byId[it] } } } - } catch (e: OpenIAPError) { + } catch (e: OpenIapError) { throw OpenIapException(toErrorJson(e)) } @@ -406,13 +406,13 @@ class HybridRnIap : HybridRnIapSpec() { if (androidRequest == null) { RnIapLog.warn("requestPurchase called without android payload") - sendPurchaseError(toErrorResult(OpenIAPError.DeveloperError())) + sendPurchaseError(toErrorResult(OpenIapError.DeveloperError())) return@async defaultResult } if (androidRequest.skus.isEmpty()) { RnIapLog.warn("requestPurchase received empty SKU list") - sendPurchaseError(toErrorResult(OpenIAPError.EmptySkuList)) + sendPurchaseError(toErrorResult(OpenIapError.EmptySkuList)) return@async defaultResult } @@ -427,7 +427,7 @@ class HybridRnIap : HybridRnIapSpec() { if (activity == null) { RnIapLog.warn("requestPurchase: Activity is null - cannot start purchase flow") - sendPurchaseError(toErrorResult(OpenIAPError.MissingCurrentActivity)) + sendPurchaseError(toErrorResult(OpenIapError.MissingCurrentActivity)) return@async defaultResult } @@ -449,7 +449,7 @@ class HybridRnIap : HybridRnIapSpec() { } fetched.firstOrNull()?.let { productTypeBySku[it.id] = it.type.rawValue } if (!productTypeBySku.containsKey(sku)) { - sendPurchaseError(toErrorResult(OpenIAPError.SkuNotFound(sku))) + sendPurchaseError(toErrorResult(OpenIapError.SkuNotFound(sku))) return@async defaultResult } } @@ -544,7 +544,7 @@ class HybridRnIap : HybridRnIapSpec() { RnIapLog.failure("requestPurchase", e) sendPurchaseError( toErrorResult( - error = OpenIAPError.PurchaseFailed(), + error = OpenIapError.PurchaseFailed(), debugMessage = e.message, messageOverride = e.message ) @@ -646,7 +646,7 @@ class HybridRnIap : HybridRnIapSpec() { nitroSubscriptions.toTypedArray() } catch (e: Exception) { RnIapLog.failure("getActiveSubscriptions", e) - val error = OpenIAPError.ServiceUnavailable() + val error = OpenIapError.ServiceUnavailable() throw OpenIapException( toErrorJson( error = error, @@ -673,7 +673,7 @@ class HybridRnIap : HybridRnIapSpec() { hasActive } catch (e: Exception) { RnIapLog.failure("hasActiveSubscriptions", e) - val error = OpenIAPError.ServiceUnavailable() + val error = OpenIapError.ServiceUnavailable() throw OpenIapException( toErrorJson( error = error, @@ -708,7 +708,7 @@ class HybridRnIap : HybridRnIapSpec() { NitroPurchaseResult( responseCode = -1.0, debugMessage = "Missing purchaseToken", - code = OpenIAPError.toCode(OpenIAPError.DeveloperError()), + code = OpenIapError.toCode(OpenIapError.DeveloperError()), message = "Missing purchaseToken", purchaseToken = null ) @@ -719,12 +719,12 @@ class HybridRnIap : HybridRnIapSpec() { try { ensureConnection() } catch (e: Exception) { - val err = OpenIAPError.InitConnection + val err = OpenIapError.InitConnection return@async Variant_Boolean_NitroPurchaseResult.Second( NitroPurchaseResult( responseCode = -1.0, debugMessage = e.message, - code = OpenIAPError.toCode(err), + code = OpenIapError.toCode(err), message = e.message?.takeIf { it.isNotBlank() } ?: err.message, purchaseToken = purchaseToken ) @@ -749,13 +749,13 @@ class HybridRnIap : HybridRnIapSpec() { RnIapLog.result("finishTransaction", mapOf("success" to true)) result } catch (e: Exception) { - val err = OpenIAPError.BillingError() + val err = OpenIapError.BillingError() RnIapLog.failure("finishTransaction", e) Variant_Boolean_NitroPurchaseResult.Second( NitroPurchaseResult( responseCode = -1.0, debugMessage = e.message, - code = OpenIAPError.toCode(err), + code = OpenIapError.toCode(err), message = e.message?.takeIf { it.isNotBlank() } ?: err.message, purchaseToken = null ) @@ -1271,14 +1271,14 @@ class HybridRnIap : HybridRnIapSpec() { // iOS-specific method - not supported on Android override fun getStorefrontIOS(): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } // iOS-specific method - not supported on Android override fun getAppTransactionIOS(): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } @@ -1365,7 +1365,7 @@ class HybridRnIap : HybridRnIapSpec() { try { // For Android, we need the google options to be provided (new platform-specific structure) val nitroGoogleOptions = (params.google as? Variant_NullType_NitroReceiptValidationGoogleOptions.Second)?.value - ?: throw OpenIapException(toErrorJson(OpenIAPError.DeveloperError(), debugMessage = "Missing required parameter: google options")) + ?: throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Missing required parameter: google options")) // Validate required google fields val validations = mapOf( @@ -1376,7 +1376,7 @@ class HybridRnIap : HybridRnIapSpec() { ) for ((name, value) in validations) { if (value.isEmpty()) { - throw OpenIapException(toErrorJson(OpenIAPError.DeveloperError(), debugMessage = "Missing or empty required parameter: $name")) + throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Missing or empty required parameter: $name")) } } @@ -1404,7 +1404,7 @@ class HybridRnIap : HybridRnIapSpec() { // Cast to Android result type (on Android, verifyPurchase returns VerifyPurchaseResultAndroid) val androidResult = verifyResult as? VerifyPurchaseResultAndroid - ?: throw OpenIapException(toErrorJson(OpenIAPError.InvalidPurchaseVerification, debugMessage = "Unexpected result type from verifyPurchase")) + ?: throw OpenIapException(toErrorJson(OpenIapError.InvalidPurchaseVerification, debugMessage = "Unexpected result type from verifyPurchase")) // Convert OpenIAP result to Nitro result val result = NitroReceiptValidationResultAndroid( @@ -1436,7 +1436,7 @@ class HybridRnIap : HybridRnIapSpec() { } catch (e: Exception) { RnIapLog.failure("validateReceipt", e) val debugMessage = e.message - val error = OpenIAPError.InvalidPurchaseVerification + val error = OpenIapError.InvalidPurchaseVerification throw OpenIapException( toErrorJson( error = error, @@ -1501,7 +1501,7 @@ class HybridRnIap : HybridRnIapSpec() { ) } catch (e: Exception) { RnIapLog.failure("verifyPurchaseWithProvider", e) - val error = OpenIAPError.VerificationFailed + val error = OpenIapError.VerificationFailed throw OpenIapException( toErrorJson( error = error, @@ -1516,37 +1516,37 @@ class HybridRnIap : HybridRnIapSpec() { // iOS-specific methods - Not applicable on Android, return appropriate defaults override fun subscriptionStatusIOS(sku: String): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun currentEntitlementIOS(sku: String): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun latestTransactionIOS(sku: String): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun getPendingTransactionsIOS(): Promise> { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun getAllTransactionsIOS(): Promise> { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun syncIOS(): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } @@ -1554,37 +1554,37 @@ class HybridRnIap : HybridRnIapSpec() { override fun isEligibleForIntroOfferIOS(groupID: String): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun getReceiptDataIOS(): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun getReceiptIOS(): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun requestReceiptRefreshIOS(): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun isTransactionVerifiedIOS(sku: String): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun getTransactionJwsIOS(sku: String): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } @@ -1614,7 +1614,7 @@ class HybridRnIap : HybridRnIapSpec() { RnIapLog.payload("showAlternativeBillingDialogAndroid", null) try { val activity = context.currentActivity - ?: throw OpenIapException(toErrorJson(OpenIAPError.DeveloperError(), debugMessage = "Activity not available")) + ?: throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Activity not available")) val userAccepted = withContext(Dispatchers.Main) { openIap.setActivity(activity) @@ -1811,7 +1811,7 @@ class HybridRnIap : HybridRnIapSpec() { val activity = withContext(Dispatchers.Main) { runCatching { context.currentActivity }.getOrNull() - } ?: throw OpenIapException(toErrorJson(OpenIAPError.DeveloperError(), debugMessage = "Activity not available")) + } ?: throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Activity not available")) val openIapParams = OpenIapLaunchExternalLinkParams( billingProgram = mapBillingProgram(params.billingProgram), @@ -1866,75 +1866,75 @@ class HybridRnIap : HybridRnIapSpec() { override fun canPresentExternalPurchaseNoticeIOS(): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun presentExternalPurchaseNoticeSheetIOS(): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun presentExternalPurchaseLinkIOS(url: String): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } // ExternalPurchaseCustomLink (iOS 18.1+) - iOS only stubs override fun isEligibleForExternalPurchaseCustomLinkIOS(): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } override fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): Promise { return Promise.async { - throw OpenIapException(toErrorJson(OpenIAPError.FeatureNotSupported())) + throw OpenIapException(toErrorJson(OpenIapError.FeatureNotSupported())) } } // --------------------------------------------------------------------- // OpenIAP error helpers: unify error codes/messages from library // --------------------------------------------------------------------- - private fun parseOpenIapError(err: Throwable): OpenIAPError { - // Try to extract OpenIAPError from the exception chain + private fun parseOpenIapError(err: Throwable): OpenIapError { + // Try to extract OpenIapError from the exception chain var cause: Throwable? = err while (cause != null) { val message = cause.message ?: "" // Check if message contains OpenIAP error patterns when { message.contains("not prepared", ignoreCase = true) || - message.contains("not initialized", ignoreCase = true) -> return OpenIAPError.NotPrepared + message.contains("not initialized", ignoreCase = true) -> return OpenIapError.NotPrepared message.contains("developer error", ignoreCase = true) || - message.contains("activity not available", ignoreCase = true) -> return OpenIAPError.DeveloperError() - message.contains("network", ignoreCase = true) -> return OpenIAPError.NetworkError + message.contains("activity not available", ignoreCase = true) -> return OpenIapError.DeveloperError() + message.contains("network", ignoreCase = true) -> return OpenIapError.NetworkError message.contains("service unavailable", ignoreCase = true) || - message.contains("billing unavailable", ignoreCase = true) -> return OpenIAPError.ServiceUnavailable() + message.contains("billing unavailable", ignoreCase = true) -> return OpenIapError.ServiceUnavailable() } cause = cause.cause } // Default to ServiceUnavailable if we can't determine the error type - return OpenIAPError.ServiceUnavailable() + return OpenIapError.ServiceUnavailable() } private fun toErrorJson( - error: OpenIAPError, + error: OpenIapError, productId: String? = null, debugMessage: String? = null, messageOverride: String? = null ): String { - val code = OpenIAPError.Companion.toCode(error) + val code = OpenIapError.Companion.toCode(error) val message = messageOverride?.takeIf { it.isNotBlank() } ?: error.message?.takeIf { it.isNotBlank() } - ?: OpenIAPError.Companion.defaultMessage(code) + ?: OpenIapError.Companion.defaultMessage(code) val diagnostics = error.toJSON() val responseCode = (diagnostics["responseCode"] as? Number)?.toInt() val productIds = diagnostics["productIds"] as? List<*> @@ -2011,15 +2011,15 @@ class HybridRnIap : HybridRnIapSpec() { } private fun toErrorResult( - error: OpenIAPError, + error: OpenIapError, productId: String? = null, debugMessage: String? = null, messageOverride: String? = null ): NitroPurchaseResult { - val code = OpenIAPError.Companion.toCode(error) + val code = OpenIapError.Companion.toCode(error) val message = messageOverride?.takeIf { it.isNotBlank() } ?: error.message?.takeIf { it.isNotBlank() } - ?: OpenIAPError.Companion.defaultMessage(code) + ?: OpenIapError.Companion.defaultMessage(code) val diagnostics = error.toJSON() val responseCode = (diagnostics["responseCode"] as? Number)?.toDouble() val diagnosticMessage = diagnostics["debugMessage"] as? String diff --git a/packages/docs/src/pages/docs/errors.tsx b/packages/docs/src/pages/docs/errors.tsx index 77255b99..0bae88e1 100644 --- a/packages/docs/src/pages/docs/errors.tsx +++ b/packages/docs/src/pages/docs/errors.tsx @@ -110,7 +110,7 @@ var message: String # Human-readable message var product_id: Variant = null # Related product SKU (if applicable) var debug_message: Variant = null # Raw diagnostic from the billing layer var response_code: Variant = null # Android QueryProduct BillingResult.responseCode -var product_ids: Variant = null # Android QueryProduct requested IDs +var product_ids: Array[String] = [] # Android QueryProduct requested IDs var product_type: Variant = null # Android BillingClient product type var is_empty_product_list: Variant = null # Android returned no products`} ), From 4a8f4c49ef10b47882a97de7f9d949438a98ccbd Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 02:03:23 +0900 Subject: [PATCH 7/7] fix(google): make query product diagnostics immutable --- .../main/java/dev/hyo/openiap/OpenIapError.kt | 71 +++++++------------ .../java/dev/hyo/openiap/OpenIapErrorTest.kt | 15 ++-- 2 files changed, 34 insertions(+), 52 deletions(-) diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt index 8c306893..299e8afb 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt @@ -160,63 +160,44 @@ sealed class OpenIapError : Exception() { const val MESSAGE = "Failed to initialize billing connection" } - object QueryProduct : OpenIapError() { - private data class Diagnostics( - val responseCode: Int? = null, - val debugMessage: String? = null, - val productIds: List = emptyList(), - val productType: String? = null, - val isEmptyProductList: Boolean? = null, - ) - - // Keep QueryProduct as the existing singleton for patch-level API compatibility. - @Volatile - private var diagnostics: Diagnostics? = null - - val CODE = ErrorCode.QueryProduct.rawValue - override val code = CODE + open class QueryProduct( + val responseCode: Int? = null, + override val debugMessage: String? = null, + val productIds: List = emptyList(), + val productType: String? = null, + val isEmptyProductList: Boolean? = null, + ) : OpenIapError() { + override val code = ErrorCode.QueryProduct.rawValue override val message = MESSAGE - override val debugMessage: String? - get() = diagnostics?.debugMessage - - val responseCode: Int? - get() = diagnostics?.responseCode - - val productIds: List - get() = diagnostics?.productIds ?: emptyList() - - val productType: String? - get() = diagnostics?.productType - - val isEmptyProductList: Boolean? - get() = diagnostics?.isEmptyProductList - - const val MESSAGE = "Failed to query product" - fun withDiagnostics( - responseCode: Int? = null, - debugMessage: String? = null, - productIds: List = emptyList(), - productType: String? = null, - isEmptyProductList: Boolean? = null, - ): QueryProduct { - diagnostics = Diagnostics( + companion object : QueryProduct() { + val CODE = ErrorCode.QueryProduct.rawValue + const val MESSAGE = "Failed to query product" + + fun withDiagnostics( + responseCode: Int? = null, + debugMessage: String? = null, + productIds: List = emptyList(), + productType: String? = null, + isEmptyProductList: Boolean? = null, + ): QueryProduct = QueryProduct( responseCode = responseCode, debugMessage = debugMessage, productIds = productIds, productType = productType, isEmptyProductList = isEmptyProductList, ) - return this } override fun toJSON(): Map { - val current = diagnostics ?: return super.toJSON() + if (responseCode == null && productIds.isEmpty() && productType == null && isEmptyProductList == null) { + return super.toJSON() + } return super.toJSON() + mapOf( - "responseCode" to current.responseCode, - "productIds" to current.productIds, - "productType" to current.productType, - "isEmptyProductList" to current.isEmptyProductList, + "responseCode" to responseCode, + "productIds" to productIds, + "productType" to productType, + "isEmptyProductList" to isEmptyProductList, ) } } diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt index 85d24f0f..8beb7a94 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/OpenIapErrorTest.kt @@ -3,7 +3,6 @@ package dev.hyo.openiap import com.android.billingclient.api.BillingClient import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -119,7 +118,7 @@ class OpenIapErrorTest { @Test fun `QueryProduct carries billing diagnostics when provided`() { - val error = OpenIapError.QueryProduct.withDiagnostics( + val error: OpenIapError = OpenIapError.QueryProduct.withDiagnostics( responseCode = BillingClient.BillingResponseCode.DEVELOPER_ERROR, debugMessage = "Invalid product ID", productIds = listOf("premium_monthly", "lifetime"), @@ -128,14 +127,16 @@ class OpenIapErrorTest { ) val json = error.toJSON() - assertSame(OpenIapError.QueryProduct, error) + assertTrue(error is OpenIapError.QueryProduct) + assertFalse(error === OpenIapError.QueryProduct) assertEquals(ErrorCode.QueryProduct.rawValue, error.code) assertEquals("Failed to query product", error.message) - assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, error.responseCode) + val queryError = error as OpenIapError.QueryProduct + assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, queryError.responseCode) assertEquals("Invalid product ID", error.debugMessage) - assertEquals(listOf("premium_monthly", "lifetime"), error.productIds) - assertEquals(BillingClient.ProductType.SUBS, error.productType) - assertEquals(true, error.isEmptyProductList) + assertEquals(listOf("premium_monthly", "lifetime"), queryError.productIds) + assertEquals(BillingClient.ProductType.SUBS, queryError.productType) + assertEquals(true, queryError.isEmptyProductList) assertEquals(BillingClient.BillingResponseCode.DEVELOPER_ERROR, json["responseCode"]) assertEquals("Invalid product ID", json["debugMessage"]) assertEquals(listOf("premium_monthly", "lifetime"), json["productIds"])