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
2 changes: 1 addition & 1 deletion openiap-versions.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"gql": "1.2.5",
"gql": "1.2.4",
"docs": "1.2.5",
"google": "1.3.5",
"apple": "1.2.32"
Expand Down
3 changes: 1 addition & 2 deletions packages/apple/Sources/Models/OpenIapSerialization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,7 @@ public enum OpenIapSerialization {
return value
}
let iosSubscriptions = allItems.compactMap { item -> ProductSubscriptionIOS? in
guard case .subscription(let subscription) = item,
case .productSubscriptionIos(let value) = subscription
guard case .productSubscription(.productSubscriptionIos(let value)) = item
else { return nil }
return value
}
Expand Down
40 changes: 6 additions & 34 deletions packages/apple/Sources/Models/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,43 +245,10 @@ public struct ExternalPurchaseNoticeResultIOS: Codable {
public var result: ExternalPurchaseNoticeAction
}


// Union type for FetchProductsResult.all
public enum ProductOrSubscription: Codable {
case product(Product)
case subscription(ProductSubscription)

public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let product = try? container.decode(Product.self) {
self = .product(product)
return
}
if let subscription = try? container.decode(ProductSubscription.self) {
self = .subscription(subscription)
return
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Cannot decode ProductOrSubscription"
)
}

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .product(let product):
try container.encode(product)
case .subscription(let subscription):
try container.encode(subscription)
}
}
}

public enum FetchProductsResult {
case all([ProductOrSubscription]?)
case products([Product]?)
case subscriptions([ProductSubscription]?)
case all([ProductOrSubscription]?)
}

public struct PricingPhaseAndroid: Codable {
Expand Down Expand Up @@ -1032,6 +999,11 @@ public enum Product: Codable, ProductCommon {
}
}

public enum ProductOrSubscription: Codable {
case product(Product)
case productSubscription(ProductSubscription)
}

public enum ProductSubscription: Codable, ProductCommon {
case productSubscriptionAndroid(ProductSubscriptionAndroid)
case productSubscriptionIos(ProductSubscriptionIOS)
Expand Down
3 changes: 1 addition & 2 deletions packages/apple/Sources/OpenIapModule+ObjC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ import StoreKit
return value
}
let subscriptionIOS = allItems.compactMap { item -> ProductSubscriptionIOS? in
guard case .subscription(let subscription) = item,
case .productSubscriptionIos(let value) = subscription
guard case .productSubscription(.productSubscriptionIos(let value)) = item
else { return nil }
return value
}
Expand Down
4 changes: 2 additions & 2 deletions packages/apple/Sources/OpenIapStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ public final class OpenIapStore: ObservableObject {
return nil
}
subscriptions = allItems.compactMap { item in
if case .subscription(let subscription) = item {
return subscription
if case .productSubscription(.productSubscriptionIos(let subscription)) = item {
return .productSubscriptionIos(subscription)
}
return nil
}
Expand Down
35 changes: 26 additions & 9 deletions packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
Original file line number Diff line number Diff line change
Expand Up @@ -712,21 +712,14 @@ public data class ExternalPurchaseNoticeResultIOS(
)
}


// Union type for FetchProductsResult.all
public sealed interface ProductOrSubscription {
data class ProductItem(val value: Product) : ProductOrSubscription
data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription
}

public sealed interface FetchProductsResult

public data class FetchProductsResultAll(val value: List<ProductOrSubscription>?) : FetchProductsResult

public data class FetchProductsResultProducts(val value: List<Product>?) : FetchProductsResult

public data class FetchProductsResultSubscriptions(val value: List<ProductSubscription>?) : FetchProductsResult

public data class FetchProductsResultAll(val value: List<ProductOrSubscription>?) : FetchProductsResult

public data class PricingPhaseAndroid(
val billingCycleCount: Int,
val billingPeriod: String,
Expand Down Expand Up @@ -2170,6 +2163,30 @@ public sealed interface Product : ProductCommon {
}
}

public sealed interface ProductOrSubscription {
fun toJson(): Map<String, Any?>

companion object {
fun fromJson(json: Map<String, Any?>): ProductOrSubscription {
return when (json["__typename"] as String?) {
"ProductAndroid" -> ProductItem(Product.fromJson(json))
"ProductIOS" -> ProductItem(Product.fromJson(json))
"ProductSubscriptionAndroid" -> ProductSubscriptionItem(ProductSubscription.fromJson(json))
"ProductSubscriptionIOS" -> ProductSubscriptionItem(ProductSubscription.fromJson(json))
else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}")
}
}
}

data class ProductItem(val value: Product) : ProductOrSubscription {
override fun toJson() = value.toJson()
}

data class ProductSubscriptionItem(val value: ProductSubscription) : ProductOrSubscription {
override fun toJson() = value.toJson()
}
}

public sealed interface ProductSubscription : ProductCommon {
fun toJson(): Map<String, Any?>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,15 +280,19 @@ class OpenIapStore(private val module: OpenIapProtocol) {
}
is FetchProductsResultAll -> {
// Handle the all case - merge both products and subscriptions
// The result.value is List<ProductOrSubscription>? containing union of Product and ProductSubscription
// The result.value is List<ProductOrSubscription>? containing union wrappers
val items = result.value ?: emptyList()

// Extract products and subscriptions from ProductOrSubscription union
// Extract Android-specific products and subscriptions from wrapper classes
val allProducts = items.mapNotNull {
(it as? ProductOrSubscription.ProductItem)?.value
(it as? ProductOrSubscription.ProductItem)?.value?.let { product ->
if (product is ProductAndroid) product else null
}
}
val allSubs = items.mapNotNull {
(it as? ProductOrSubscription.SubscriptionItem)?.value
(it as? ProductOrSubscription.ProductSubscriptionItem)?.value?.let { subscription ->
if (subscription is ProductSubscriptionAndroid) subscription else null
}
}

// Merge products
Expand Down
31 changes: 20 additions & 11 deletions packages/gql/scripts/fix-generated-types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,18 @@ for (const file of schemaDefinitionFiles) {
}
}

// Extend FetchProductsResult to support mixed arrays for 'all' type
// MUST be done BEFORE interface parsing to ensure optionalUnionInterfaces map has the correct union
// The generated union `Product[] | ProductSubscription[] | null` doesn't support mixed arrays
// Add `(Product | ProductSubscription)[]` to the union to enable type narrowing
const fetchProductsResultPattern = /export type FetchProductsResult = Product\[\] \| ProductSubscription\[\] \| null;/;
if (fetchProductsResultPattern.test(content)) {
content = content.replace(
fetchProductsResultPattern,
'export type FetchProductsResult = Product[] | ProductSubscription[] | (Product | ProductSubscription)[] | null;'
);
}

const singleFieldInterfaceTypes = new Map();
const optionalUnionInterfaces = new Map();
const interfacePattern = /export interface (\w+) \{\n([\s\S]*?)\n\}\n/g;
Expand Down Expand Up @@ -489,17 +501,6 @@ for (const [name, unionType] of optionalUnionInterfaces) {
content = content.replace(pattern, `export type ${name} = ${unionType};\n\n`);
}

// Extend FetchProductsResult to support mixed arrays for 'all' type
// The generated union `Product[] | ProductSubscription[] | null` doesn't support mixed arrays
// Add `(Product | ProductSubscription)[]` to the union to enable type narrowing
const fetchProductsResultPattern = /export type FetchProductsResult = Product\[\] \| ProductSubscription\[\] \| null;/;
if (fetchProductsResultPattern.test(content)) {
content = content.replace(
fetchProductsResultPattern,
'export type FetchProductsResult = Product[] | ProductSubscription[] | (Product | ProductSubscription)[] | null;'
);
}

const futureFields = new Set();
for (const file of schemaFiles) {
let previousWasMarker = false;
Expand Down Expand Up @@ -548,6 +549,14 @@ for (const [name, unionType] of optionalUnionInterfaces) {
}
}

// Fix Query interface to use FetchProductsResult type alias instead of inline union
// This ensures the Query['fetchProducts'] return type matches our implementation
// Must be done AFTER singleFieldInterfaceTypes replacement expands the type
content = content.replace(
/fetchProducts: Promise<\(Product\[\] \| ProductSubscription\[\] \| \(Product \| ProductSubscription\)\[\] \| null\)>/g,
'fetchProducts: Promise<FetchProductsResult>'
);

content = content.replace(/^\s*_placeholder\??: [^;]+;\n/gm, '');

const ROOT_DEFINITIONS = ['Query', 'Mutation', 'Subscription'];
Expand Down
125 changes: 79 additions & 46 deletions packages/gql/scripts/generate-dart-types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -668,16 +668,28 @@ const printUnion = (unionType) => {
let sharedInterfaceNames = [];
if (memberTypes.length > 0) {
const [firstMember, ...otherMembers] = memberTypes;
const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name));
for (const member of otherMembers) {
const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name));
for (const ifaceName of Array.from(firstInterfaces)) {
if (!memberInterfaces.has(ifaceName)) {
firstInterfaces.delete(ifaceName);
// Check if member is a union (unions don't have getInterfaces)
if (typeof firstMember.getInterfaces === 'function') {
const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name));
let allMembersHaveInterfaces = true;
for (const member of otherMembers) {
if (typeof member.getInterfaces === 'function') {
const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name));
for (const ifaceName of Array.from(firstInterfaces)) {
if (!memberInterfaces.has(ifaceName)) {
firstInterfaces.delete(ifaceName);
}
}
} else {
// Member is a union, so no shared interfaces
allMembersHaveInterfaces = false;
break;
}
}
if (allMembersHaveInterfaces) {
sharedInterfaceNames = Array.from(firstInterfaces).sort();
}
}
sharedInterfaceNames = Array.from(firstInterfaces).sort();
}

const implementsClause = sharedInterfaceNames.length ? ` implements ${sharedInterfaceNames.join(', ')}` : '';
Expand All @@ -687,9 +699,54 @@ const printUnion = (unionType) => {
lines.push(` factory ${unionType.name}.fromJson(Map<String, dynamic> json) {`);
lines.push(` final typeName = json['__typename'] as String?;`);
lines.push(' switch (typeName) {');
members.forEach((member) => {
lines.push(` case '${member}':`, ` return ${member}.fromJson(json);`);

// Flatten nested unions: if a member is itself a union, include its concrete members
const concreteMembers = new Set();
for (const memberType of memberTypes) {
if (isUnionType(memberType)) {
// Member is a union, get its concrete members
const nestedMembers = memberType.getTypes();
for (const nestedMember of nestedMembers) {
concreteMembers.add(nestedMember.name);
}
} else {
// Member is a concrete type
concreteMembers.add(memberType.name);
}
}

// Track nested unions that need wrapper classes
const nestedUnions = new Set();

// Generate case for each concrete member, wrapping nested unions
const sortedConcreteMembers = Array.from(concreteMembers).sort();
sortedConcreteMembers.forEach((concreteMember) => {
// Find which direct member this concrete type belongs to
let delegateTo = concreteMember;
let isNestedUnion = false;

for (const memberType of memberTypes) {
if (isUnionType(memberType)) {
const nestedMembers = memberType.getTypes().map(t => t.name);
if (nestedMembers.includes(concreteMember)) {
delegateTo = memberType.name;
isNestedUnion = true;
nestedUnions.add(memberType.name);
break;
}
}
}

if (isNestedUnion) {
// Wrap nested union in a typed wrapper class
const wrapperName = `${unionType.name}${delegateTo}`;
lines.push(` case '${concreteMember}':`, ` return ${wrapperName}(${delegateTo}.fromJson(json));`);
} else {
// Direct member, no wrapping needed
lines.push(` case '${concreteMember}':`, ` return ${delegateTo}.fromJson(json);`);
}
});

lines.push(' }');
lines.push(` throw ArgumentError('Unknown __typename for ${unionType.name}: $typeName');`);
lines.push(' }', '');
Expand Down Expand Up @@ -722,6 +779,18 @@ const printUnion = (unionType) => {

lines.push(' Map<String, dynamic> toJson();');
lines.push('}', '');

// Generate wrapper classes for nested unions
for (const nestedUnionName of Array.from(nestedUnions).sort()) {
const wrapperName = `${unionType.name}${nestedUnionName}`;
lines.push(`class ${wrapperName} extends ${unionType.name} {`);
lines.push(` const ${wrapperName}(this.value);`);
lines.push(` final ${nestedUnionName} value;`);
lines.push('');
lines.push(' @override');
lines.push(' Map<String, dynamic> toJson() => value.toJson();');
lines.push('}', '');
}
};

const expandInputToParams = (inputTypeName) => {
Expand Down Expand Up @@ -989,45 +1058,9 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) {
}
}

// Post-process: Add ProductOrSubscription union class
// This allows FetchProductsResult.all to contain heterogeneous lists
const productOrSubscriptionUnion = `
// Union type for FetchProductsResult.all
abstract class ProductOrSubscription {
const ProductOrSubscription();
}

class ProductOrSubscriptionProduct extends ProductOrSubscription {
const ProductOrSubscriptionProduct(this.value);
final Product value;
}

class ProductOrSubscriptionSubscription extends ProductOrSubscription {
const ProductOrSubscriptionSubscription(this.value);
final ProductSubscription value;
}
`;

// All unions including nested ones are auto-generated with proper wrapper classes
let output = lines.join('\n');

// Insert ProductOrSubscription before FetchProductsResult
const fetchProductsResultAbstractPattern = /abstract class FetchProductsResult \{/;
if (fetchProductsResultAbstractPattern.test(output)) {
output = output.replace(
fetchProductsResultAbstractPattern,
productOrSubscriptionUnion + '\nabstract class FetchProductsResult {'
);
}

// Add the 'all' case to FetchProductsResult
const fetchProductsResultPattern = /(class FetchProductsResultSubscriptions extends FetchProductsResult \{\n const FetchProductsResultSubscriptions\(this\.value\);\n final List<ProductSubscription>\? value;\n\})/;
if (fetchProductsResultPattern.test(output)) {
output = output.replace(
fetchProductsResultPattern,
'$1\n\nclass FetchProductsResultAll extends FetchProductsResult {\n const FetchProductsResultAll(this.value);\n final List<ProductOrSubscription>? value;\n}'
);
}

// Fix enum default values - Dart uses PascalCase for enum values
output = output.replace(/IapPlatform\.ios/g, 'IapPlatform.IOS');
output = output.replace(/IapPlatform\.android/g, 'IapPlatform.Android');
Expand Down
Loading
Loading