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 2699ddd2c..96a73deb8 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 @@ -402,43 +402,32 @@ class HybridRnIap : HybridRnIapSpec() { val skusList = skus.toList() val products: List = try { - when (queryType) { - ProductQueryType.All -> { - 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( - "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) } - ) + RnIapLog.payload( + "fetchProducts.native", + mapOf("skus" to skusList, "type" to queryType.rawValue) + ) + // Pass the query type through unchanged: the native module resolves + // ProductQueryType.All itself, so each sku keeps its real product type + // instead of being shadowed by a per-type not-found placeholder. + 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] } - } + // Match the iOS bridge's result shape: preserve input order, + // drop duplicate skus, then append any fetched products that + // were not requested. + val byId = fetched.associateBy { it.id } + val seenIds = mutableSetOf() + val orderedProducts = mutableListOf() + skusList.forEach { sku -> + byId[sku]?.takeIf { seenIds.add(it.id) }?.let(orderedProducts::add) + } + fetched.forEach { product -> + if (seenIds.add(product.id)) orderedProducts.add(product) } + orderedProducts } catch (e: OpenIapError) { throw OpenIapException(toErrorJson(e)) } 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 deleted file mode 100644 index bc6839463..000000000 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductQueryHelpers.kt +++ /dev/null @@ -1,42 +0,0 @@ -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 = coroutineScope { - val byId = linkedMapOf() - var firstFailure: Throwable? = null - - 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) - } - }.onFailure { error -> - if (error is CancellationException) throw error - onFailure(kind, error) - if (firstFailure == null) firstFailure = error - } - } - - if (byId.isEmpty()) { - firstFailure?.let { throw 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 deleted file mode 100644 index 450d60093..000000000 --- a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductQueryHelpersTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -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 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") - 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/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 8b103e676..f137b16e9 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -22,6 +22,10 @@ interface Note { element: React.ReactNode; } +const fetchProductsAllFixReleases = [ + ['react-native-iap 15.5.4', 'react-native-iap-15.5.4'], +] as const; + const crossSdkAuditReleases = [ ['react-native-iap 15.5.3', 'react-native-iap-15.5.3'], ['expo-iap 4.6.0', 'expo-iap-4.6.0'], @@ -70,6 +74,106 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // July 20, 2026 - react-native-iap 15.5.4 + { + id: 'react-native-iap-fetch-products-all-fix-2026-07-20', + date: new Date('2026-07-20'), + element: ( +
+ + July 20, 2026 - react-native-iap 15.5.4 + + +

+ Publishes a focused Android patch for the React Native library. The + OpenIAP Spec and native package versions are unchanged. The + regression was reported in{' '} + + issue #238 + {' '} + and fixed in{' '} + + PR #239 + + . +

+ +
React Native
+
    +
  • + react-native-iap 15.5.4 - fixes Android{' '} + fetchProducts({ type: 'all' }){' '} + returning subscriptions as in-app with{' '} + productStatusAndroid: 'not-found'. The + Android bridge previously expanded all into separate + in-app and subscription queries and merged them first-match-wins, + so the in-app query's not-found placeholder row shadowed the + real subscription. The bridge now delegates all to + the native query, so each sku keeps its real product type and + status, and the internal product-type cache no longer mislabels + subscription skus (which could break a subsequent{' '} + requestPurchase). +
  • +
+ +
+
Package Releases
+
    + {fetchProductsAllFixReleases.map(([label, tag]) => ( +
  • + + {label} + +
  • + ))} +
+
+
+ ), + }, + // July 20, 2026 - Cross-SDK audit fixes and IAPKit verified-state accuracy { id: 'cross-sdk-audit-fixes-2026-07-20',