diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt index 2f08fb94e..a5fd56fe4 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt @@ -3,6 +3,7 @@ package expo.modules.iap import android.content.Context import dev.hyo.openiap.AndroidSubscriptionOfferInput import dev.hyo.openiap.DeepLinkOptions +import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions import dev.hyo.openiap.InitConnectionConfig @@ -183,9 +184,9 @@ class ExpoIapModule : Module() { val result = openIap.fetchProducts(request) val payload = when (result) { + is FetchProductsResultAll -> result.value.orEmpty().map { it.toJson() } is FetchProductsResultProducts -> result.value.orEmpty().map { it.toJson() } is FetchProductsResultSubscriptions -> result.value.orEmpty().map { it.toJson() } - else -> emptyList>() } ExpoIapLog.result("fetchProducts", payload) promise.resolve(payload) diff --git a/libraries/expo-iap/ios/onside/OnsideIapModule.swift b/libraries/expo-iap/ios/onside/OnsideIapModule.swift index cf6f7d78d..da760e366 100644 --- a/libraries/expo-iap/ios/onside/OnsideIapModule.swift +++ b/libraries/expo-iap/ios/onside/OnsideIapModule.swift @@ -50,6 +50,7 @@ public final class ExpoIapOnsideModule: Module { private let transactionObserver = OnsideTransactionObserverBridge() private let productFetcher = OnsideProductFetcher() private var productCache: [String: OnsideProduct] = [:] + private var transactionDateCache: [String: Date] = [:] nonisolated public func definition() -> ModuleDefinition { Name("ExpoIapOnside") @@ -113,7 +114,7 @@ public final class ExpoIapOnsideModule: Module { // Check if Onside Store is installed if let onsideURL = URL(string: "onside://"), - UIApplication.shared.canOpenURL(onsideURL) { + await MainActor.run(body: { UIApplication.shared.canOpenURL(onsideURL) }) { #if DEBUG print("[ExpoIapOnsideModule] ✅ Onside Store app is installed") #endif @@ -225,16 +226,15 @@ public final class ExpoIapOnsideModule: Module { let productId = purchasePayload["productId"] as? String let txId = purchasePayload["transactionId"] as? String - let queue = await Onside.defaultPaymentQueue() - let transaction: OnsidePaymentTransaction? = await MainActor.run { + let transactions = Onside.defaultPaymentQueue().transactions if let txId, !txId.isEmpty { - return queue.transactions.first(where: { $0.transactionIdentifier == txId }) + return transactions.first(where: { $0.transactionIdentifier == txId }) } // 2) fallback: if txId is not available yet — search by productId (less reliable!) if let productId, !productId.isEmpty { - return queue.transactions.first(where: { + return transactions.first(where: { $0.payment.product.productIdentifier == productId && ($0.transactionState == .purchased || $0.transactionState == .restored) }) @@ -247,7 +247,9 @@ public final class ExpoIapOnsideModule: Module { throw OnsideBridgeError.transactionNotFound(txId ?? productId ?? "") } - await queue.finishTransaction(transaction) + await MainActor.run { + Onside.defaultPaymentQueue().finishTransaction(transaction) + } ExpoIapLog.result("finishTransactionOnside", value: true) return true } @@ -298,14 +300,21 @@ public final class ExpoIapOnsideModule: Module { ] ) try await ensureObserverRegistered() - let queue = await Onside.defaultPaymentQueue() - let payload = try queue.transactions.compactMap { transaction -> [String: Any]? in - switch transaction.transactionState { - case .purchased, .restored: - return try serialize(transaction: transaction) - default: - return nil + let payload: [[String: Any]] = try await MainActor.run { + let items = try Onside.defaultPaymentQueue().transactions.compactMap { transaction -> [String: Any]? in + switch transaction.transactionState { + case .purchased, .restored: + return try serialize(transaction: transaction) + default: + return nil + } } + if alsoPublish { + items.forEach { + sendEvent(OnsideEvent.purchaseUpdated.rawValue, $0) + } + } + return items } ExpoIapLog.result("getAvailableItemsOnside", value: payload) return payload @@ -323,7 +332,7 @@ public final class ExpoIapOnsideModule: Module { private func getOnsideStorefront() async throws -> String { ExpoIapLog.payload("getStorefrontOnside", payload: nil) try await ensureObserverRegistered() - let storefront = await Onside.defaultPaymentQueue().storefront?.countryCode ?? "" + let storefront = Onside.defaultPaymentQueue().storefront?.countryCode ?? "" ExpoIapLog.result("getStorefrontOnside", value: storefront) return storefront } @@ -385,6 +394,7 @@ public final class ExpoIapOnsideModule: Module { let cont = restoreContinuation restoreContinuation = nil cont?.resume(returning: false) + transactionDateCache.removeAll() } private func handle(transaction: OnsidePaymentTransaction) { @@ -425,11 +435,11 @@ public final class ExpoIapOnsideModule: Module { dictionary["displayNameIOS"] = product.localizedTitle let formatter = NumberFormatter() formatter.numberStyle = .currency - formatter.currencyCode = product.price.currencyCode ?? "" - let priceNumber = NSDecimalNumber(decimal: product.price.value) + formatter.currencyCode = product.price.currencyCode + let priceNumber = NSDecimalNumber(string: String(product.price.value)) let formattedPrice = formatter.string(from: priceNumber) ?? "\(product.price.value)" dictionary["displayPrice"] = formattedPrice - dictionary["currency"] = product.price.currencyCode ?? "" + dictionary["currency"] = product.price.currencyCode dictionary["price"] = priceNumber dictionary["type"] = "in-app" dictionary["typeIOS"] = "non-consumable" @@ -450,15 +460,15 @@ public final class ExpoIapOnsideModule: Module { dictionary["quantity"] = 1 dictionary["isAutoRenewing"] = false dictionary["purchaseState"] = mapPurchaseState(transaction.transactionState) - let txDate = transaction.transactionDate ?? Date() + let txDate = date(for: transaction) dictionary["transactionDate"] = Int(txDate.timeIntervalSince1970 * 1000) - dictionary["currencyCodeIOS"] = product.price.currencyCode ?? "" + dictionary["currencyCodeIOS"] = product.price.currencyCode let currencyFormatter = NumberFormatter() currencyFormatter.numberStyle = .currency - currencyFormatter.currencyCode = product.price.currencyCode ?? "" + currencyFormatter.currencyCode = product.price.currencyCode dictionary["currencySymbolIOS"] = currencyFormatter.currencySymbol ?? "" - dictionary["storefrontCountryCodeIOS"] = transaction.storefront.countryCode ?? "" + dictionary["storefrontCountryCodeIOS"] = transaction.storefront.countryCode dictionary["purchaseToken"] = nil dictionary["environmentIOS"] = transaction.storefront.id if let error = transaction.error { @@ -467,12 +477,30 @@ public final class ExpoIapOnsideModule: Module { return sanitize(dictionary) } + private func date(for transaction: OnsidePaymentTransaction) -> Date { + guard let key = transaction.transactionIdentifier ?? transaction.originalTransactionIdentifier, + !key.isEmpty + else { + return Date() + } + + if let cachedDate = transactionDateCache[key] { + return cachedDate + } + + // OnsideKit currently exposes no purchase date on the transaction, so + // keep the first observed timestamp stable for repeated serializations. + let observedDate = Date() + transactionDateCache[key] = observedDate + return observedDate + } + // Build a JSON string from known product fields (no Encodable conformance required) private func makeProductJSONRepresentation(from product: OnsideProduct) throws -> String { let priceFormatter = NumberFormatter() priceFormatter.numberStyle = .currency - priceFormatter.currencyCode = product.price.currencyCode ?? "" - let priceNumber = NSDecimalNumber(decimal: product.price.value) + priceFormatter.currencyCode = product.price.currencyCode + let priceNumber = NSDecimalNumber(string: String(product.price.value)) let formattedPrice = priceFormatter.string(from: priceNumber) ?? "\(product.price.value)" let jsonObject: [String: Any] = [ "id": product.productIdentifier, @@ -480,7 +508,7 @@ public final class ExpoIapOnsideModule: Module { "description": product.localizedDescription, "price": [ "value": priceNumber, - "currencyCode": product.price.currencyCode ?? "", + "currencyCode": product.price.currencyCode, "formatted": formattedPrice, ], "isFamilyShareable": false, @@ -655,6 +683,7 @@ private final class OnsideProductFetcher: NSObject, OnsideProductsRequestDelegat } } + @MainActor private func cleanup() { request?.delegate = nil request?.stop() diff --git a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index 1c587ab4f..a333992d5 100644 --- a/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -15,6 +15,7 @@ import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid import dev.hyo.openiap.ExternalLinkLaunchModeAndroid import dev.hyo.openiap.ExternalLinkTypeAndroid import dev.hyo.openiap.FetchProductsResult +import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions import dev.hyo.openiap.InitConnectionConfig @@ -87,11 +88,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act deduplicate: Boolean = false ): JSONArray { val entries: List> = when (result) { + is FetchProductsResultAll -> result.value?.map { it.toJson() } + ?: emptyList() is FetchProductsResultProducts -> result.value?.map { it.toJson() } ?: emptyList() is FetchProductsResultSubscriptions -> result.value?.map { it.toJson() } ?: emptyList() - else -> emptyList>() } val array = JSONArray() val seenIds = mutableSetOf() diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index f6096b0ff..90e6cb92f 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -2734,16 +2734,34 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { queryType: queryType, ); - // Wrap list in appropriate union type for OpenIAP compatibility + // Wrap list in the generated result union for OpenIAP compatibility. + // `All` must preserve product and subscription variants instead of + // flattening the mixed result into the product-only branch. + if (queryType == gentype.ProductQueryType.All) { + final wrapped = products + .map((product) { + if (product is gentype.ProductSubscription) { + return gentype.ProductOrSubscriptionProductSubscription( + product, + ); + } + if (product is gentype.Product) { + return gentype.ProductOrSubscriptionProduct(product); + } + return null; + }) + .whereType() + .toList(growable: false); + return gentype.FetchProductsResultAll(wrapped); + } if (queryType == gentype.ProductQueryType.Subs) { return gentype.FetchProductsResultSubscriptions( products.whereType().toList(), ); - } else { - return gentype.FetchProductsResultProducts( - products.whereType().toList(), - ); } + return gentype.FetchProductsResultProducts( + products.whereType().toList(), + ); }; gentype.QueryHandlers get queryHandlers => gentype.QueryHandlers( diff --git a/libraries/flutter_inapp_purchase/test/fetch_products_all_test.dart b/libraries/flutter_inapp_purchase/test/fetch_products_all_test.dart index 64f62d72e..0292bb8e8 100644 --- a/libraries/flutter_inapp_purchase/test/fetch_products_all_test.dart +++ b/libraries/flutter_inapp_purchase/test/fetch_products_all_test.dart @@ -99,4 +99,44 @@ void main() { expect(subscriptions, hasLength(1)); expect(subscriptions.first.id, 'premium_monthly'); }); + + test('fetchProducts returns mixed products when querying all type', () async { + final platform = FakePlatform(operatingSystem: 'ios'); + final iap = FlutterInappPurchase.private(platform); + + await iap.initConnection(); + + final products = await iap.fetchProducts( + skus: const ['premium_monthly', 'coin_pack'], + type: types.ProductQueryType.All, + ); + + expect(products, hasLength(2)); + expect(products.whereType(), hasLength(1)); + expect(products.whereType(), hasLength(1)); + }); + + test('queryHandlers fetchProducts preserves all result union', () async { + final platform = FakePlatform(operatingSystem: 'ios'); + final iap = FlutterInappPurchase.private(platform); + + await iap.initConnection(); + + final result = await iap.queryHandlers.fetchProducts!( + skus: const ['premium_monthly', 'coin_pack'], + type: types.ProductQueryType.All, + ); + + expect(result, isA()); + final allResult = result as types.FetchProductsResultAll; + expect(allResult.value, hasLength(2)); + expect( + allResult.value, + contains(isA()), + ); + expect( + allResult.value, + contains(isA()), + ); + }); } diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 3e21680d4..b879b6d78 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -36,6 +36,7 @@ import io.github.hyochan.kmpiap.openiap.ExternalPurchaseNoticeResultIOS import io.github.hyochan.kmpiap.openiap.InitConnectionConfig import io.github.hyochan.kmpiap.openiap.UserChoiceBillingDetails import io.github.hyochan.kmpiap.openiap.FetchProductsResult +import io.github.hyochan.kmpiap.openiap.FetchProductsResultAll import io.github.hyochan.kmpiap.openiap.FetchProductsResultProducts import io.github.hyochan.kmpiap.openiap.FetchProductsResultSubscriptions import io.github.hyochan.kmpiap.openiap.MutationDeepLinkToSubscriptionsHandler @@ -46,6 +47,7 @@ import io.github.hyochan.kmpiap.openiap.MutationRequestPurchaseHandler import io.github.hyochan.kmpiap.openiap.MutationValidateReceiptHandler import io.github.hyochan.kmpiap.openiap.MutationHandlers import io.github.hyochan.kmpiap.openiap.Product +import io.github.hyochan.kmpiap.openiap.ProductOrSubscription import io.github.hyochan.kmpiap.openiap.ProductQueryType import io.github.hyochan.kmpiap.openiap.ProductRequest import io.github.hyochan.kmpiap.openiap.Purchase @@ -775,7 +777,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife PurchaseError(code = ErrorCode.EmptySkuList, message = "SKU list is empty") ) - val queryType = params.type ?: ProductQueryType.All + val queryType = params.type ?: ProductQueryType.InApp val includeInApp = queryType == ProductQueryType.InApp || queryType == ProductQueryType.All val includeSubs = queryType == ProductQueryType.Subs || queryType == ProductQueryType.All @@ -835,11 +837,19 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife ProductQueryType.InApp -> FetchProductsResultProducts(inAppDetails.map { it.toProduct() }) ProductQueryType.Subs -> FetchProductsResultSubscriptions(subsDetails.mapNotNull { it.toSubscriptionProduct() }) ProductQueryType.All -> { - val combined = buildList { - addAll(inAppDetails.map { it.toProduct() }) - addAll(subsDetails.map { it.toProduct() }) + // Preserve the mixed OpenIAP `all` union by keeping in-app + // products and subscriptions in their distinct variants. + val combined = buildList { + addAll(inAppDetails.map { ProductOrSubscription.ProductItem(it.toProduct()) }) + addAll( + subsDetails.mapNotNull { detail -> + detail.toSubscriptionProduct()?.let { + ProductOrSubscription.ProductSubscriptionItem(it) + } + } + ) } - FetchProductsResultProducts(combined) + FetchProductsResultAll(combined) } } } diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 0ab9a9f29..cc7903a1b 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -474,13 +474,21 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { val subscriptions = convertAnyListToProductSubscriptions(result) continuation.resume(FetchProductsResultSubscriptions(subscriptions)) } + ProductQueryType.All -> { + val items = convertAnyListToProductOrSubscriptions(result) + continuation.resume(FetchProductsResultAll(items)) + } else -> { val products = convertAnyListToProducts(result) continuation.resume(FetchProductsResultProducts(products)) } } } else { - continuation.resume(FetchProductsResultProducts(emptyList())) + when (params.type) { + ProductQueryType.Subs -> continuation.resume(FetchProductsResultSubscriptions(emptyList())) + ProductQueryType.All -> continuation.resume(FetchProductsResultAll(emptyList())) + else -> continuation.resume(FetchProductsResultProducts(emptyList())) + } } } } @@ -1260,6 +1268,33 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } + private fun convertAnyListToProductOrSubscriptions(data: Any?): List { + if (data == null) return emptyList() + + val list = data as? List<*> ?: return emptyList() + return list.mapNotNull { item -> + runCatching { + val dict = (item as? Map<*, *>) ?: return@runCatching null + val map = dict.mapKeys { it.key.toString() } + + // Native iOS may return either generated union JSON or raw StoreKit maps; + // decode the union first, then recover by product type for legacy payloads. + runCatching { ProductOrSubscription.fromJson(map) }.getOrElse { + val type = map["type"] as? String + if (type?.equals(ProductType.Subs.rawValue, ignoreCase = true) == true) { + convertAnyListToProductSubscriptions(listOf(item)).firstOrNull()?.let { + ProductOrSubscription.ProductSubscriptionItem(it) + } + } else { + convertAnyListToProducts(listOf(item)).firstOrNull()?.let { + ProductOrSubscription.ProductItem(it) + } + } + } + }.getOrNull() + } + } + @Suppress("UNCHECKED_CAST") private fun convertAnyToProductIOS(data: Any?): ProductIOS? { if (data == null) return null diff --git a/packages/docs/src/pages/docs/apis/fetch-products.tsx b/packages/docs/src/pages/docs/apis/fetch-products.tsx index 47a6d864c..2305c781c 100644 --- a/packages/docs/src/pages/docs/apis/fetch-products.tsx +++ b/packages/docs/src/pages/docs/apis/fetch-products.tsx @@ -12,12 +12,15 @@ function FetchProducts() {

fetchProducts

-

Retrieve products or subscriptions from the store by SKU.

+

+ Retrieve in-app products, subscriptions, or a mixed result from the + store by SKU. +

iOS: Wraps Product.products(for:){' '} (StoreKit 2). Fetches the localized price/title for each SKU. Unknown @@ -41,7 +44,10 @@ function FetchProducts() { > Google docs - . + . When type is 'all', Android + queries both INAPP and SUBS product details + and returns a mixed result that preserves product and subscription + variants.

@@ -113,9 +119,9 @@ type FetchProductsResult = {`suspend fun fetchProducts(request: ProductRequest): FetchProductsResult`} ), dart: ( - {`Future fetchProducts({ + {`Future> fetchProducts({ required List skus, - ProductQueryType? type, + ProductQueryType type = ProductQueryType.InApp, });`} ), gdscript: ( @@ -196,8 +202,11 @@ public sealed record FetchProductsResultAll(IReadOnlyList (for type: 'all') {' '} - — Mixed array; use each entry's type field to - disambiguate. + — Mixed array that preserves each item as either a product or a + subscription. TypeScript-based wrappers expose this as a flat + discriminated union; generated Kotlin, Dart, and C# schema handlers + expose the same contract through their language-specific union + wrappers.
  • null (legacy) — Older schema branch retained for @@ -205,6 +214,136 @@ public sealed record FetchProductsResultAll(IReadOnlyList
  • + + Fetch all product types + +

    + Use type: 'all' only when you intentionally want + one request to return both in-app products and subscriptions. If{' '} + type is omitted, OpenIAP defaults to in-app products. +

    + + {{ + typescript: ( + {`import { fetchProducts } from 'expo-iap'; + +const items = await fetchProducts({ + skus: ['com.app.coins_100', 'com.app.monthly'], + type: 'all', +}); + +for (const item of items ?? []) { + if (item.type === 'subs') { + console.log('subscription', item.id, item.subscriptionOffers); + } else { + console.log('in-app product', item.id, item.displayPrice); + } +}`} + ), + swift: ( + {`let result = try await OpenIapModule.shared.fetchProducts( + ProductRequest(skus: ["com.app.coins_100", "com.app.monthly"], type: .all) +) + +if case let .all(items) = result { + for item in items ?? [] { + switch item { + case let .product(product): + print("in-app product \\(product.id)") + case let .productSubscription(subscription): + print("subscription \\(subscription.id)") + } + } +}`} + ), + kotlin: ( + {`val result = openIapStore.fetchProducts( + ProductRequest( + skus = listOf("com.app.coins_100", "com.app.monthly"), + type = ProductQueryType.All, + ) +) + +if (result is FetchProductsResultAll) { + result.value.orEmpty().forEach { item -> + when (item) { + is ProductOrSubscription.ProductItem -> + println("in-app product " + item.value.id) + is ProductOrSubscription.ProductSubscriptionItem -> + println("subscription " + item.value.id) + } + } +}`} + ), + kmp: ( + {`val result = kmpIAP.fetchProducts( + ProductRequest( + skus = listOf("com.app.coins_100", "com.app.monthly"), + type = ProductQueryType.All, + ) +) + +if (result is FetchProductsResultAll) { + result.value.orEmpty().forEach { item -> + when (item) { + is ProductOrSubscription.ProductItem -> + println("in-app product " + item.value.id) + is ProductOrSubscription.ProductSubscriptionItem -> + println("subscription " + item.value.id) + } + } +}`} + ), + dart: ( + {`final items = await FlutterInappPurchase.instance + .fetchProducts( + skus: ['com.app.coins_100', 'com.app.monthly'], + type: ProductQueryType.All, +); + +for (final item in items) { + switch (item.type) { + case ProductType.Subs: + print('subscription ' + item.id); + case ProductType.InApp: + print('in-app product ' + item.id); + } +}`} + ), + gdscript: ( + {`var request = ProductRequest.new() +request.skus = ["com.app.coins_100", "com.app.monthly"] +request.type = ProductQueryType.ALL +var items = await iap.fetch_products(request) + +for item in items: + if item.type == ProductType.SUBS: + print("subscription ", item.id) + else: + print("in-app product ", item.id)`} + ), + csharp: ( + {`var result = await iap.FetchProductsAsync(new ProductRequest { + Skus = new[] { "com.app.coins_100", "com.app.monthly" }, + Type = ProductQueryType.All, +}); + +if (result is FetchProductsResultAll allResult) { + foreach (var item in allResult.Value ?? Array.Empty()) { + switch (item) { + case Product product: + Console.WriteLine($"in-app product {product.Id}"); + break; + case ProductSubscription subscription: + Console.WriteLine($"subscription {subscription.Id}"); + break; + } + } +}`} + ), + }} + +

    Example

    {{ @@ -283,16 +422,14 @@ val products = kmpIAP.fetchProducts { }`}
    ), dart: ( - {`final FetchProductsResult result = await FlutterInappPurchase.instance.fetchProducts( + {`final products = await FlutterInappPurchase.instance.fetchProducts( skus: ['com.app.premium'], type: ProductQueryType.InApp, ); -// fetchProducts returns a sealed FetchProductsResult — unwrap by variant. -final List products = switch (result) { - FetchProductsResultProducts(value: final list) => list ?? [], - _ => [], -};`} +for (final product in products) { + print(product.title + ': ' + product.displayPrice); +}`} ), gdscript: ( {`var request = ProductRequest.new() diff --git a/packages/docs/src/pages/docs/types/product-request.tsx b/packages/docs/src/pages/docs/types/product-request.tsx index 960510631..69bd02c8d 100644 --- a/packages/docs/src/pages/docs/types/product-request.tsx +++ b/packages/docs/src/pages/docs/types/product-request.tsx @@ -153,20 +153,22 @@ val allProducts = openIapStore.fetchProducts( ), dart: ( {`// Fetch in-app purchases (default) -final inappProducts = await FlutterInappPurchase.instance.fetchProducts( +final inappProducts = await FlutterInappPurchase.instance.fetchProducts( skus: ['product1', 'product2'], ); // Fetch only subscriptions -final subscriptions = await FlutterInappPurchase.instance.fetchProducts( +final subscriptions = await FlutterInappPurchase.instance + .fetchProducts( skus: ['sub1', 'sub2'], - type: ProductQueryType.subs, + type: ProductQueryType.Subs, ); // Fetch all products (both in-app and subscriptions) -final allProducts = await FlutterInappPurchase.instance.fetchProducts( +final allProducts = await FlutterInappPurchase.instance + .fetchProducts( skus: ['product1', 'sub1'], - type: ProductQueryType.all, + type: ProductQueryType.All, );`} ), csharp: ( diff --git a/packages/docs/src/pages/docs/types/request-purchase-props.tsx b/packages/docs/src/pages/docs/types/request-purchase-props.tsx index 3bd3b2194..a83746004 100644 --- a/packages/docs/src/pages/docs/types/request-purchase-props.tsx +++ b/packages/docs/src/pages/docs/types/request-purchase-props.tsx @@ -218,7 +218,7 @@ await FlutterInappPurchase.instance.requestPurchase( apple: RequestPurchaseIosProps(sku: 'monthly_sub'), google: RequestPurchaseAndroidProps(skus: ['monthly_sub']), ), - type: ProductQueryType.subs, + type: ProductQueryType.Subs, ), );`} ), diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index b29b52faf..c44d9c71d 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,6 +26,198 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // June 26, 2026 — fetchProducts all mixed-result parity hotfix + { + id: 'fetch-products-all-parity-hotfix-2026-06-26', + date: new Date('2026-06-26'), + element: ( +
    + + June 26, 2026 — fetchProducts all mixed-result parity hotfix + + +

    + Publishes a cross-SDK hotfix for{' '} + {'fetchProducts({ type: "all" })'} so combined product + queries preserve both in-app products and subscriptions instead of + collapsing to an empty or product-only result. The{' '} + OpenIAP Spec remains 2.0.2; this release updates + Android bridge/result mapping and wrapper compatibility layers for{' '} + openiap-google, expo-iap,{' '} + flutter_inapp_purchase, and kmp-iap. Track + the regression in{' '} + + issue #192 + {' '} + and{' '} + + PR #193 + + . +

    + +
      +
    • + Mixed all results preserved — explicit{' '} + ProductQueryType.All now returns the generated{' '} + FetchProductsResultAll branch in Google Play, + Horizon, Expo Android, Flutter Android, and KMP Android/iOS paths + that participate in the shared result contract. +
    • +
    • + Default product queries aligned — omitted{' '} + ProductRequest.type falls back to{' '} + ProductQueryType.InApp, matching the schema default + and docs while keeping explicit All behavior + available. +
    • +
    • + Flutter compatibility fixed — the public{' '} + fetchProducts<T>() convenience API keeps + returning typed lists, while{' '} + queryHandlers.fetchProducts now wraps{' '} + All results as FetchProductsResultAll{' '} + instead of dropping subscriptions through the product-only branch. +
    • +
    • + KMP iOS bridge hardened — mixed payload decoding + now preserves valid items even when one native bridge payload + fails to decode, and falls back by product type only after the + generated union decoder has been tried. +
    • +
    • + Expo Onside iOS bridge fixes — queue mutations + run on the main actor, available-purchase publishing honors{' '} + alsoPublishToEventListenerIOS, transaction dates stay + stable across repeated serialization, and price serialization + avoids direct binary floating-point formatting. +
    • +
    • + Audited unchanged SDKs — React Native, Godot, and + MAUI already preserve the mixed result contract and do not require + code changes in this release. +
    • +
    + +
    +
    Package Releases
    + +
    +
    + ), + }, + // June 23, 2026 — Flutter Swift Package Manager support { id: 'flutter-swift-package-manager-support-2026-06-23', 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 8b67acea4..d79822510 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 @@ -33,7 +33,6 @@ import dev.hyo.openiap.utils.HorizonBillingConverters.toActiveSubscription import dev.hyo.openiap.utils.HorizonBillingConverters.toInAppProduct import dev.hyo.openiap.utils.HorizonBillingConverters.toPurchase import dev.hyo.openiap.utils.HorizonBillingConverters.toSubscriptionProduct -import dev.hyo.openiap.utils.toProduct import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay import dev.hyo.openiap.utils.verifyPurchaseWithHorizon import dev.hyo.openiap.MutationVerifyPurchaseHandler @@ -184,7 +183,7 @@ class OpenIapModule( OpenIapLog.i("Requested SKUs: ${params.skus}", TAG) OpenIapLog.i("Query type: ${params.type}", TAG) - val queryType = params.type ?: ProductQueryType.All + val queryType = params.type ?: ProductQueryType.InApp val includeInApp = queryType == ProductQueryType.InApp || queryType == ProductQueryType.All val includeSubs = queryType == ProductQueryType.Subs || queryType == ProductQueryType.All @@ -220,11 +219,11 @@ class OpenIapModule( ProductQueryType.InApp -> FetchProductsResultProducts(inAppProducts) ProductQueryType.Subs -> FetchProductsResultSubscriptions(subscriptionProducts) ProductQueryType.All -> { - val combined = buildList { - addAll(inAppProducts) - addAll(subscriptionProducts.map(ProductSubscriptionAndroid::toProduct)) + val combined = buildList { + addAll(inAppProducts.map { ProductOrSubscription.ProductItem(it) }) + addAll(subscriptionProducts.map { ProductOrSubscription.ProductSubscriptionItem(it) }) } - FetchProductsResultProducts(combined) + FetchProductsResultAll(combined) } } } 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 35bbfce5a..f4ee1925b 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 @@ -205,7 +205,7 @@ class OpenIapModule( if (!client.isReady) throw OpenIapError.NotPrepared if (params.skus.isEmpty() && params.type != ProductQueryType.All) throw OpenIapError.EmptySkuList - val queryType = params.type ?: ProductQueryType.All + val queryType = params.type ?: ProductQueryType.InApp when (queryType) { ProductQueryType.InApp -> {