Skip to content
This repository was archived by the owner on Oct 17, 2025. It is now read-only.

Commit 4b51e6b

Browse files
authored
fix: fetchProduct with ProductQueryType.All (#12)
Fix the product duplication issue when using `ProductQueryType.All` by adding deduplication logic that skips already processed product IDs. This prevents the same products from being added twice when they appear in both InApp and Subs queries.
1 parent a75f3b4 commit 4b51e6b

File tree

4 files changed

+65
-34
lines changed

4 files changed

+65
-34
lines changed

CONVENTION.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Project Conventions
22

3+
## Naming Conventions
4+
5+
### Enum Values
6+
- Enum values in this codebase must use **kebab-case** (e.g., `non-consumable`, `in-app`, `user-cancelled`)
7+
- This matches the convention used in the auto-generated Types.kt from GraphQL schemas
8+
- Do not use snake_case (e.g., `non_consumable`) or camelCase for enum raw values
9+
310
## Generated GraphQL/Kotlin Models
411

512
- `openiap/src/main/Types.kt` is auto-generated. Regenerate it with `./scripts/generate-types.sh` after changing any GraphQL schema files.

Example/src/main/java/dev/hyo/martie/Constants.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ object IapConstants {
44
// App-defined SKU lists
55
val INAPP_SKUS = listOf(
66
"dev.hyo.martie.10bulbs",
7-
"dev.hyo.martie.30bulbs"
7+
"dev.hyo.martie.30bulbs",
8+
"dev.hyo.martie.certified" // Non-consumable
89
)
910

1011
val SUBS_SKUS = listOf(
11-
"dev.hyo.martie.premium"
12+
"dev.hyo.martie.premium",
13+
"dev.hyo.martie.premium_year"
1214
)
1315
}
1416

Example/src/main/java/dev/hyo/martie/screens/AllProductsScreen.kt

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,11 @@ fun AllProductsScreen(
6262
val connected = iapStore.initConnection()
6363
if (connected) {
6464
iapStore.setActivity(activity)
65-
// Fetch in-app products and subscriptions separately
66-
// This ensures proper type classification
65+
// Fetch all products at once using ProductQueryType.All
66+
// This fetches both in-app and subscription products in a single call
6767
iapStore.fetchProducts(
68-
skus = IapConstants.INAPP_SKUS,
69-
type = ProductQueryType.InApp
70-
)
71-
iapStore.fetchProducts(
72-
skus = IapConstants.SUBS_SKUS,
73-
type = ProductQueryType.Subs
68+
skus = IapConstants.INAPP_SKUS + IapConstants.SUBS_SKUS,
69+
type = ProductQueryType.All
7470
)
7571
}
7672
} catch (_: Exception) { }
@@ -143,14 +139,10 @@ fun AllProductsScreen(
143139
val connected = iapStore.initConnection()
144140
if (connected) {
145141
iapStore.setActivity(activity)
146-
// Fetch products after reconnecting - separately to ensure proper types
147-
iapStore.fetchProducts(
148-
skus = IapConstants.INAPP_SKUS,
149-
type = ProductQueryType.InApp
150-
)
142+
// Fetch all products after reconnecting using ProductQueryType.All
151143
iapStore.fetchProducts(
152-
skus = IapConstants.SUBS_SKUS,
153-
type = ProductQueryType.Subs
144+
skus = IapConstants.INAPP_SKUS + IapConstants.SUBS_SKUS,
145+
type = ProductQueryType.All
154146
)
155147
}
156148
} catch (_: Exception) { }

openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -115,26 +115,56 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
115115
if (params.skus.isEmpty() && params.type != ProductQueryType.All) throw OpenIapError.EmptySkuList
116116

117117
val queryType = params.type ?: ProductQueryType.All
118-
val includeInApp = queryType == ProductQueryType.InApp || queryType == ProductQueryType.All
119-
val includeSubs = queryType == ProductQueryType.Subs || queryType == ProductQueryType.All
120-
121-
val inAppProducts = if (includeInApp) {
122-
queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.INAPP)
123-
.map { it.toInAppProduct() }
124-
} else emptyList()
125-
126-
val subscriptionProducts = if (includeSubs) {
127-
queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.SUBS)
128-
.map { it.toSubscriptionProduct() }
129-
} else emptyList()
130118

131119
when (queryType) {
132-
ProductQueryType.InApp -> FetchProductsResultProducts(inAppProducts)
133-
ProductQueryType.Subs -> FetchProductsResultSubscriptions(subscriptionProducts)
120+
ProductQueryType.InApp -> {
121+
val inAppProducts = queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.INAPP)
122+
.map { it.toInAppProduct() }
123+
FetchProductsResultProducts(inAppProducts)
124+
}
125+
ProductQueryType.Subs -> {
126+
val subscriptionProducts = queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.SUBS)
127+
.map { it.toSubscriptionProduct() }
128+
FetchProductsResultSubscriptions(subscriptionProducts)
129+
}
134130
ProductQueryType.All -> {
135-
// For All type, combine products and return as Products result
136-
val allProducts = inAppProducts + subscriptionProducts.filterIsInstance<ProductSubscriptionAndroid>().map { it.toProduct() }
137-
FetchProductsResultProducts(allProducts)
131+
// Query both types and combine results
132+
val allProducts = mutableListOf<Product>()
133+
val processedIds = mutableSetOf<String>()
134+
135+
// First, get all INAPP products
136+
val inAppDetails = runCatching {
137+
queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.INAPP)
138+
}.getOrDefault(emptyList())
139+
140+
inAppDetails.forEach { detail ->
141+
val product = detail.toInAppProduct()
142+
allProducts.add(product)
143+
processedIds.add(detail.productId)
144+
}
145+
146+
// Then, get subscription products (only add if not already processed as INAPP)
147+
val subsDetails = runCatching {
148+
queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.SUBS)
149+
}.getOrDefault(emptyList())
150+
151+
subsDetails.forEach { detail ->
152+
if (detail.productId !in processedIds) {
153+
// Keep subscription as ProductSubscription, but convert to Product for return
154+
val subProduct = detail.toSubscriptionProduct()
155+
allProducts.add(subProduct.toProduct())
156+
}
157+
}
158+
159+
// Return products in the order they were requested if SKUs provided
160+
val orderedProducts = if (params.skus.isNotEmpty()) {
161+
val productMap = allProducts.associateBy { it.id }
162+
params.skus.mapNotNull { productMap[it] }
163+
} else {
164+
allProducts
165+
}
166+
167+
FetchProductsResultProducts(orderedProducts)
138168
}
139169
}
140170
}

0 commit comments

Comments
 (0)