Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Map<String, Any?>>()
}
ExpoIapLog.result("fetchProducts", payload)
promise.resolve(payload)
Expand Down
77 changes: 53 additions & 24 deletions libraries/expo-iap/ios/onside/OnsideIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
})
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Comment thread
hyochan marked this conversation as resolved.
ExpoIapLog.result("getAvailableItemsOnside", value: payload)
return payload
Expand All @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -467,20 +477,38 @@ 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,
"title": product.localizedTitle,
"description": product.localizedDescription,
"price": [
"value": priceNumber,
"currencyCode": product.price.currencyCode ?? "",
"currencyCode": product.price.currencyCode,
"formatted": formattedPrice,
],
"isFamilyShareable": false,
Expand Down Expand Up @@ -655,6 +683,7 @@ private final class OnsideProductFetcher: NSObject, OnsideProductsRequestDelegat
}
}

@MainActor
private func cleanup() {
request?.delegate = nil
request?.stop()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,11 +88,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
deduplicate: Boolean = false
): JSONArray {
val entries: List<Map<String, Any?>> = 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<Map<String, Any?>>()
}
val array = JSONArray()
val seenIds = mutableSetOf<String>()
Expand Down
28 changes: 23 additions & 5 deletions libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<gentype.ProductOrSubscription?>((product) {
if (product is gentype.ProductSubscription) {
return gentype.ProductOrSubscriptionProductSubscription(
product,
);
}
if (product is gentype.Product) {
return gentype.ProductOrSubscriptionProduct(product);
}
return null;
})
.whereType<gentype.ProductOrSubscription>()
.toList(growable: false);
return gentype.FetchProductsResultAll(wrapped);
}
if (queryType == gentype.ProductQueryType.Subs) {
return gentype.FetchProductsResultSubscriptions(
products.whereType<gentype.ProductSubscription>().toList(),
);
} else {
return gentype.FetchProductsResultProducts(
products.whereType<gentype.Product>().toList(),
);
}
return gentype.FetchProductsResultProducts(
products.whereType<gentype.Product>().toList(),
);
};

gentype.QueryHandlers get queryHandlers => gentype.QueryHandlers(
Expand Down
40 changes: 40 additions & 0 deletions libraries/flutter_inapp_purchase/test/fetch_products_all_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<types.ProductCommon>(
skus: const ['premium_monthly', 'coin_pack'],
type: types.ProductQueryType.All,
);

expect(products, hasLength(2));
expect(products.whereType<types.ProductSubscription>(), hasLength(1));
expect(products.whereType<types.Product>(), 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<types.FetchProductsResultAll>());
final allResult = result as types.FetchProductsResultAll;
expect(allResult.value, hasLength(2));
expect(
allResult.value,
contains(isA<types.ProductOrSubscriptionProductSubscription>()),
);
expect(
allResult.value,
contains(isA<types.ProductOrSubscriptionProduct>()),
);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
val includeInApp = queryType == ProductQueryType.InApp || queryType == ProductQueryType.All
val includeSubs = queryType == ProductQueryType.Subs || queryType == ProductQueryType.All

Expand Down Expand Up @@ -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 -> {
Comment thread
hyochan marked this conversation as resolved.
val combined = buildList<Product> {
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<ProductOrSubscription> {
addAll(inAppDetails.map { ProductOrSubscription.ProductItem(it.toProduct()) })
addAll(
subsDetails.mapNotNull { detail ->
detail.toSubscriptionProduct()?.let {
ProductOrSubscription.ProductSubscriptionItem(it)
}
}
)
}
FetchProductsResultProducts(combined)
FetchProductsResultAll(combined)
}
}
}
Expand Down
Loading
Loading