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 @@ -75,6 +75,14 @@ object ExpoIapHelper {
}
}

fun parsePurchaseProductQueryType(rawType: String?): ProductQueryType {
val type = parseProductQueryType(rawType)
require(type != ProductQueryType.All) {
"Product type all is only supported for product queries."
}
return type
}

internal fun parseDeepLinkSubscriptionParams(params: Map<String, Any?>): DeepLinkSubscriptionParams {
return DeepLinkSubscriptionParams(
sku = params["skuAndroid"] as? String,
Expand All @@ -87,67 +95,107 @@ object ExpoIapHelper {
// before parsing the native request.
val effective: Map<String, Any?> =
run {
val request = params["request"] as? Map<*, *>
if (request != null) {
val nested = request["google"] as? Map<*, *>
if (nested != null) {
val flat = mutableMapOf<String, Any?>()
// Carry over top-level fields such as the purchase type.
for ((k, v) in params) {
if (k != "request") flat[k] = v
}
// Overlay platform-specific fields
for ((k, v) in nested) {
if (k is String) flat[k] = v
}
flat
} else {
params
if (params.containsKey("request")) {
val request = params["request"]
require(request is Map<*, *>) { "request must be an object" }
val nested = request["google"]
require(nested is Map<*, *> && nested.keys.all { it is String }) {
"request.google must be an object with string keys"
}
require(!nested.containsKey("type")) {
"type must be provided only at the purchase envelope"
}
val flat = mutableMapOf<String, Any?>()
// Carry over top-level fields such as the purchase type.
for ((k, v) in params) {
if (k != "request") flat[k] = v
}
// Overlay platform-specific fields.
for ((k, v) in nested) {
flat[k as String] = v
}
flat
} else {
params
}
}

require(effective["type"] == null || effective["type"] is String) {
"type must be a string"
}
effective["skus"]?.let { rawSkus ->
require(rawSkus is List<*> && rawSkus.all { it is String && it.isNotBlank() }) {
"skus must contain only non-empty strings"
}
}
for (key in listOf(
"obfuscatedAccountId",
"obfuscatedProfileId",
"purchaseToken",
"originalExternalTransactionId",
"offerToken",
)) {
require(effective[key] == null || effective[key] is String) { "$key must be a string" }
}
require(effective["isOfferPersonalized"] == null || effective["isOfferPersonalized"] is Boolean) {
"isOfferPersonalized must be a boolean"
}
for (key in listOf("developerBillingOption", "subscriptionProductReplacementParams")) {
effective[key]?.let { value ->
require(value is Map<*, *> && value.keys.all { it is String }) {
"$key must be an object with string keys"
}
}
}
effective["subscriptionOffers"]?.let { rawOffers ->
require(rawOffers is List<*>) { "subscriptionOffers must be a list" }
require(rawOffers.all { rawOffer ->
val offer = rawOffer as? Map<*, *> ?: return@all false
val sku = offer["sku"] as? String
val token = offer["offerToken"] as? String
offer.keys.all { it is String } && !sku.isNullOrBlank() && !token.isNullOrBlank()
}) { "subscriptionOffers must contain valid sku and offerToken strings" }
}

val type = effective["type"] as? String
val skus = (effective["skus"] as? List<*>)?.filterIsInstance<String>() ?: emptyList()
val purchaseType = parsePurchaseProductQueryType(type)
val subscriptionOnlyFields = listOf(
"subscriptionOffers",
"subscriptionProductReplacementParams",
"purchaseToken",
"originalExternalTransactionId",
)
require(
purchaseType != ProductQueryType.InApp ||
subscriptionOnlyFields.none { effective[it] != null },
) { "Subscription options require product type subs" }
require(purchaseType != ProductQueryType.Subs || effective["offerToken"] == null) {
"offerToken requires product type in-app"
}
val skus = (effective["skus"] as? List<*>)?.map { it as String } ?: emptyList()
val obfuscatedAccountId = effective["obfuscatedAccountId"] as? String
val obfuscatedProfileId = effective["obfuscatedProfileId"] as? String
val isOfferPersonalized = effective["isOfferPersonalized"] as? Boolean ?: false
val explicitSubscriptionOffers =
(effective["subscriptionOffers"] as? List<*>)?.mapNotNull { rawOffer ->
val offerMap = rawOffer as? Map<*, *> ?: return@mapNotNull null
val sku = offerMap["sku"] as? String
val offerToken = offerMap["offerToken"] as? String
if (sku.isNullOrEmpty() || offerToken.isNullOrEmpty()) {
null
} else {
AndroidSubscriptionOfferInput(offerToken = offerToken, sku = sku)
}
(effective["subscriptionOffers"] as? List<*>)?.map { rawOffer ->
val offerMap = rawOffer as Map<*, *>
val sku = offerMap["sku"] as String
val offerToken = offerMap["offerToken"] as String
AndroidSubscriptionOfferInput(offerToken = offerToken, sku = sku)
} ?: emptyList()
val purchaseToken = effective["purchaseToken"] as? String
val originalExternalTransactionId = effective["originalExternalTransactionId"] as? String
val developerBillingOption =
(effective["developerBillingOption"] as? Map<*, *>)?.let { optionMap ->
val json =
optionMap.entries
.mapNotNull { (key, value) ->
(key as? String)?.let { it to value }
}.toMap()
.associate { (key, value) -> key as String to value }
DeveloperBillingOptionParamsAndroid.fromJson(json)
}
val subscriptionProductReplacementParams =
(effective["subscriptionProductReplacementParams"] as? Map<*, *>)?.let { paramsMap ->
val oldProductId = paramsMap["oldProductId"] as? String
val replacementModeStr = paramsMap["replacementMode"] as? String
if (oldProductId.isNullOrEmpty() || replacementModeStr.isNullOrEmpty()) {
null
} else {
SubscriptionProductReplacementParamsAndroid(
oldProductId = oldProductId,
replacementMode = parseSubscriptionReplacementMode(replacementModeStr),
)
}
val json = paramsMap.entries.associate { (key, value) -> key as String to value }
SubscriptionProductReplacementParamsAndroid.fromJson(json)
}
// offerToken for one-time purchase discounts (Android 8.0+)
val offerToken = effective["offerToken"] as? String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ class ExpoIapModule : Module() {
val parsedParams = ExpoIapHelper.parseRequestPurchaseParams(params)

val productType =
when (ExpoIapHelper.parseProductQueryType(parsedParams.type)) {
when (ExpoIapHelper.parsePurchaseProductQueryType(parsedParams.type)) {
ProductQueryType.Subs -> ProductQueryType.Subs
else -> ProductQueryType.InApp
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
package expo.modules.iap

import dev.hyo.openiap.OpenIapError
import dev.hyo.openiap.ProductQueryType
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test

class ExpoIapHelperTest {
@Test
fun `purchase parser rejects all`() {
assertThrows(IllegalArgumentException::class.java) {
ExpoIapHelper.parsePurchaseProductQueryType("all")
}
assertEquals(ProductQueryType.All, ExpoIapHelper.parseProductQueryType("all"))
}

@Test
fun `deep link parser uses canonical keys`() {
val parsed =
Expand Down Expand Up @@ -72,8 +82,8 @@ class ExpoIapHelperTest {
}

@Test
fun `request parser ignores the removed android request alias`() {
val parsed =
fun `request parser rejects the removed android request alias`() {
assertThrows(IllegalArgumentException::class.java) {
ExpoIapHelper.parseRequestPurchaseParams(
mapOf(
"type" to "in-app",
Expand All @@ -84,8 +94,7 @@ class ExpoIapHelperTest {
),
),
)

assertTrue(parsed.skus.isEmpty())
}
}

@Test
Expand Down Expand Up @@ -117,6 +126,76 @@ class ExpoIapHelperTest {
assertTrue(parsed.explicitSubscriptionOffers.isEmpty())
}

@Test
fun `purchase parser rejects mixed SKU lists`() {
assertThrows(IllegalArgumentException::class.java) {
ExpoIapHelper.parseRequestPurchaseParams(
mapOf("type" to "in-app", "skus" to listOf("coins", 7)),
)
}
}

@Test
fun `purchase parser rejects malformed offers atomically`() {
assertThrows(IllegalArgumentException::class.java) {
ExpoIapHelper.parseRequestPurchaseParams(
mapOf(
"type" to "subs",
"skus" to listOf("premium"),
"subscriptionOffers" to listOf(
mapOf("sku" to "premium", "offerToken" to "known"),
mapOf("sku" to "premium"),
),
),
)
}
}

@Test
fun `purchase parser rejects a nested type discriminator`() {
assertThrows(IllegalArgumentException::class.java) {
ExpoIapHelper.parseRequestPurchaseParams(
mapOf(
"type" to "in-app",
"request" to mapOf(
"google" to mapOf(
"type" to "subs",
"skus" to listOf("premium"),
),
),
),
)
}
}

@Test
fun `purchase parser rejects branch mismatched options`() {
listOf(
mapOf(
"type" to "in-app",
"skus" to listOf("coins"),
"subscriptionOffers" to emptyList<Any>(),
),
mapOf(
"type" to "in-app",
"skus" to listOf("coins"),
"subscriptionProductReplacementParams" to mapOf(
"oldProductId" to "old",
"replacementMode" to "without-proration",
),
),
mapOf(
"type" to "subs",
"skus" to listOf("premium"),
"offerToken" to "one-time-token",
),
).forEach { request ->
assertThrows(IllegalArgumentException::class.java) {
ExpoIapHelper.parseRequestPurchaseParams(request)
}
}
}

@Test
fun `end connection preserves false and still cleans up`() =
runBlocking {
Expand Down
41 changes: 33 additions & 8 deletions libraries/expo-iap/ios/ExpoIapHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,18 @@ enum ExpoIapHelper {
}
}

static func parseProductQueryType(_ rawValue: String?) throws -> ProductQueryType {
guard let raw = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty
else {
static func parseProductQueryType(_ rawValue: Any?) throws -> ProductQueryType {
if rawValue == nil || rawValue is NSNull {
return .inApp
}
guard let stringValue = rawValue as? String else {
throw PurchaseError.make(
code: .developerError,
message: "Product type must be a string"
)
}
let raw = stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty {
return .inApp
}
switch raw.lowercased() {
Expand All @@ -117,9 +126,26 @@ enum ExpoIapHelper {
}
}

static func parsePurchaseProductQueryType(_ rawValue: Any?) throws -> ProductQueryType {
if let rawValue, !(rawValue is String), !(rawValue is NSNull) {
throw PurchaseError.make(
code: .developerError,
message: "Purchase type must be a string"
)
}
let type = try parseProductQueryType(rawValue)
guard type != .all else {
throw PurchaseError.make(
code: .developerError,
message: "Product type all is only supported for product queries."
)
}
return type
}

static func decodeProductRequest(from payload: [String: Any]) throws -> ProductRequest {
if let skus = payload["skus"] as? [String], !skus.isEmpty {
let type = try parseProductQueryType(payload["type"] as? String)
let type = try parseProductQueryType(payload["type"])
return try OpenIapSerialization.productRequest(skus: skus, type: type)
}

Expand All @@ -143,6 +169,7 @@ enum ExpoIapHelper {
static func decodeRequestPurchaseProps(from payload: [String: Any]) throws
-> RequestPurchaseProps
{
let parsedType = try parsePurchaseProductQueryType(payload["type"])
if payload.keys.contains("requestPurchase"),
payload["requestPurchase"] != nil
{
Expand All @@ -157,10 +184,8 @@ enum ExpoIapHelper {
}

if payload.keys.contains("request"), let request = payload["request"] {
let parsedType = try parseProductQueryType(payload["type"] as? String)
let purchaseType: ProductQueryType = parsedType == .all ? .inApp : parsedType
var normalized: [String: Any] = ["type": purchaseType.rawValue]
switch purchaseType {
var normalized: [String: Any] = ["type": parsedType.rawValue]
switch parsedType {
case .subs:
normalized["requestSubscription"] = request
case .inApp:
Expand Down
Loading
Loading