expo-iap 4.3.2 → 4.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ package expo.modules.iap
3
3
  import android.content.Context
4
4
  import dev.hyo.openiap.AndroidSubscriptionOfferInput
5
5
  import dev.hyo.openiap.DeepLinkOptions
6
+ import dev.hyo.openiap.FetchProductsResultAll
6
7
  import dev.hyo.openiap.FetchProductsResultProducts
7
8
  import dev.hyo.openiap.FetchProductsResultSubscriptions
8
9
  import dev.hyo.openiap.InitConnectionConfig
@@ -183,9 +184,9 @@ class ExpoIapModule : Module() {
183
184
  val result = openIap.fetchProducts(request)
184
185
  val payload =
185
186
  when (result) {
187
+ is FetchProductsResultAll -> result.value.orEmpty().map { it.toJson() }
186
188
  is FetchProductsResultProducts -> result.value.orEmpty().map { it.toJson() }
187
189
  is FetchProductsResultSubscriptions -> result.value.orEmpty().map { it.toJson() }
188
- else -> emptyList<Map<String, Any?>>()
189
190
  }
190
191
  ExpoIapLog.result("fetchProducts", payload)
191
192
  promise.resolve(payload)
@@ -50,6 +50,7 @@ public final class ExpoIapOnsideModule: Module {
50
50
  private let transactionObserver = OnsideTransactionObserverBridge()
51
51
  private let productFetcher = OnsideProductFetcher()
52
52
  private var productCache: [String: OnsideProduct] = [:]
53
+ private var transactionDateCache: [String: Date] = [:]
53
54
 
54
55
  nonisolated public func definition() -> ModuleDefinition {
55
56
  Name("ExpoIapOnside")
@@ -113,7 +114,7 @@ public final class ExpoIapOnsideModule: Module {
113
114
 
114
115
  // Check if Onside Store is installed
115
116
  if let onsideURL = URL(string: "onside://"),
116
- UIApplication.shared.canOpenURL(onsideURL) {
117
+ await MainActor.run(body: { UIApplication.shared.canOpenURL(onsideURL) }) {
117
118
  #if DEBUG
118
119
  print("[ExpoIapOnsideModule] ✅ Onside Store app is installed")
119
120
  #endif
@@ -225,16 +226,15 @@ public final class ExpoIapOnsideModule: Module {
225
226
  let productId = purchasePayload["productId"] as? String
226
227
  let txId = purchasePayload["transactionId"] as? String
227
228
 
228
- let queue = await Onside.defaultPaymentQueue()
229
-
230
229
  let transaction: OnsidePaymentTransaction? = await MainActor.run {
230
+ let transactions = Onside.defaultPaymentQueue().transactions
231
231
  if let txId, !txId.isEmpty {
232
- return queue.transactions.first(where: { $0.transactionIdentifier == txId })
232
+ return transactions.first(where: { $0.transactionIdentifier == txId })
233
233
  }
234
234
 
235
235
  // 2) fallback: if txId is not available yet — search by productId (less reliable!)
236
236
  if let productId, !productId.isEmpty {
237
- return queue.transactions.first(where: {
237
+ return transactions.first(where: {
238
238
  $0.payment.product.productIdentifier == productId
239
239
  && ($0.transactionState == .purchased || $0.transactionState == .restored)
240
240
  })
@@ -247,7 +247,9 @@ public final class ExpoIapOnsideModule: Module {
247
247
  throw OnsideBridgeError.transactionNotFound(txId ?? productId ?? "")
248
248
  }
249
249
 
250
- await queue.finishTransaction(transaction)
250
+ await MainActor.run {
251
+ Onside.defaultPaymentQueue().finishTransaction(transaction)
252
+ }
251
253
  ExpoIapLog.result("finishTransactionOnside", value: true)
252
254
  return true
253
255
  }
@@ -298,14 +300,21 @@ public final class ExpoIapOnsideModule: Module {
298
300
  ]
299
301
  )
300
302
  try await ensureObserverRegistered()
301
- let queue = await Onside.defaultPaymentQueue()
302
- let payload = try queue.transactions.compactMap { transaction -> [String: Any]? in
303
- switch transaction.transactionState {
304
- case .purchased, .restored:
305
- return try serialize(transaction: transaction)
306
- default:
307
- return nil
303
+ let payload: [[String: Any]] = try await MainActor.run {
304
+ let items = try Onside.defaultPaymentQueue().transactions.compactMap { transaction -> [String: Any]? in
305
+ switch transaction.transactionState {
306
+ case .purchased, .restored:
307
+ return try serialize(transaction: transaction)
308
+ default:
309
+ return nil
310
+ }
308
311
  }
312
+ if alsoPublish {
313
+ items.forEach {
314
+ sendEvent(OnsideEvent.purchaseUpdated.rawValue, $0)
315
+ }
316
+ }
317
+ return items
309
318
  }
310
319
  ExpoIapLog.result("getAvailableItemsOnside", value: payload)
311
320
  return payload
@@ -323,7 +332,7 @@ public final class ExpoIapOnsideModule: Module {
323
332
  private func getOnsideStorefront() async throws -> String {
324
333
  ExpoIapLog.payload("getStorefrontOnside", payload: nil)
325
334
  try await ensureObserverRegistered()
326
- let storefront = await Onside.defaultPaymentQueue().storefront?.countryCode ?? ""
335
+ let storefront = Onside.defaultPaymentQueue().storefront?.countryCode ?? ""
327
336
  ExpoIapLog.result("getStorefrontOnside", value: storefront)
328
337
  return storefront
329
338
  }
@@ -385,6 +394,7 @@ public final class ExpoIapOnsideModule: Module {
385
394
  let cont = restoreContinuation
386
395
  restoreContinuation = nil
387
396
  cont?.resume(returning: false)
397
+ transactionDateCache.removeAll()
388
398
  }
389
399
 
390
400
  private func handle(transaction: OnsidePaymentTransaction) {
@@ -425,11 +435,11 @@ public final class ExpoIapOnsideModule: Module {
425
435
  dictionary["displayNameIOS"] = product.localizedTitle
426
436
  let formatter = NumberFormatter()
427
437
  formatter.numberStyle = .currency
428
- formatter.currencyCode = product.price.currencyCode ?? ""
429
- let priceNumber = NSDecimalNumber(decimal: product.price.value)
438
+ formatter.currencyCode = product.price.currencyCode
439
+ let priceNumber = NSDecimalNumber(string: String(product.price.value))
430
440
  let formattedPrice = formatter.string(from: priceNumber) ?? "\(product.price.value)"
431
441
  dictionary["displayPrice"] = formattedPrice
432
- dictionary["currency"] = product.price.currencyCode ?? ""
442
+ dictionary["currency"] = product.price.currencyCode
433
443
  dictionary["price"] = priceNumber
434
444
  dictionary["type"] = "in-app"
435
445
  dictionary["typeIOS"] = "non-consumable"
@@ -450,15 +460,15 @@ public final class ExpoIapOnsideModule: Module {
450
460
  dictionary["quantity"] = 1
451
461
  dictionary["isAutoRenewing"] = false
452
462
  dictionary["purchaseState"] = mapPurchaseState(transaction.transactionState)
453
- let txDate = transaction.transactionDate ?? Date()
463
+ let txDate = date(for: transaction)
454
464
  dictionary["transactionDate"] = Int(txDate.timeIntervalSince1970 * 1000)
455
- dictionary["currencyCodeIOS"] = product.price.currencyCode ?? ""
465
+ dictionary["currencyCodeIOS"] = product.price.currencyCode
456
466
  let currencyFormatter = NumberFormatter()
457
467
  currencyFormatter.numberStyle = .currency
458
- currencyFormatter.currencyCode = product.price.currencyCode ?? ""
468
+ currencyFormatter.currencyCode = product.price.currencyCode
459
469
  dictionary["currencySymbolIOS"] = currencyFormatter.currencySymbol ?? ""
460
470
 
461
- dictionary["storefrontCountryCodeIOS"] = transaction.storefront.countryCode ?? ""
471
+ dictionary["storefrontCountryCodeIOS"] = transaction.storefront.countryCode
462
472
  dictionary["purchaseToken"] = nil
463
473
  dictionary["environmentIOS"] = transaction.storefront.id
464
474
  if let error = transaction.error {
@@ -467,12 +477,30 @@ public final class ExpoIapOnsideModule: Module {
467
477
  return sanitize(dictionary)
468
478
  }
469
479
 
480
+ private func date(for transaction: OnsidePaymentTransaction) -> Date {
481
+ guard let key = transaction.transactionIdentifier ?? transaction.originalTransactionIdentifier,
482
+ !key.isEmpty
483
+ else {
484
+ return Date()
485
+ }
486
+
487
+ if let cachedDate = transactionDateCache[key] {
488
+ return cachedDate
489
+ }
490
+
491
+ // OnsideKit currently exposes no purchase date on the transaction, so
492
+ // keep the first observed timestamp stable for repeated serializations.
493
+ let observedDate = Date()
494
+ transactionDateCache[key] = observedDate
495
+ return observedDate
496
+ }
497
+
470
498
  // Build a JSON string from known product fields (no Encodable conformance required)
471
499
  private func makeProductJSONRepresentation(from product: OnsideProduct) throws -> String {
472
500
  let priceFormatter = NumberFormatter()
473
501
  priceFormatter.numberStyle = .currency
474
- priceFormatter.currencyCode = product.price.currencyCode ?? ""
475
- let priceNumber = NSDecimalNumber(decimal: product.price.value)
502
+ priceFormatter.currencyCode = product.price.currencyCode
503
+ let priceNumber = NSDecimalNumber(string: String(product.price.value))
476
504
  let formattedPrice = priceFormatter.string(from: priceNumber) ?? "\(product.price.value)"
477
505
  let jsonObject: [String: Any] = [
478
506
  "id": product.productIdentifier,
@@ -480,7 +508,7 @@ public final class ExpoIapOnsideModule: Module {
480
508
  "description": product.localizedDescription,
481
509
  "price": [
482
510
  "value": priceNumber,
483
- "currencyCode": product.price.currencyCode ?? "",
511
+ "currencyCode": product.price.currencyCode,
484
512
  "formatted": formattedPrice,
485
513
  ],
486
514
  "isFamilyShareable": false,
@@ -655,6 +683,7 @@ private final class OnsideProductFetcher: NSObject, OnsideProductsRequestDelegat
655
683
  }
656
684
  }
657
685
 
686
+ @MainActor
658
687
  private func cleanup() {
659
688
  request?.delegate = nil
660
689
  request?.stop()
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "spec": "2.0.2",
3
- "google": "2.2.2",
3
+ "google": "2.2.3",
4
4
  "apple": "2.2.1"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-iap",
3
- "version": "4.3.2",
3
+ "version": "4.3.4",
4
4
  "description": "In App Purchase module in Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -8,6 +8,8 @@ type LocalPathOption = string | {
8
8
  ios?: string;
9
9
  android?: string;
10
10
  };
11
+ type GradleLanguage = 'groovy' | 'kotlin';
12
+ export declare const ensureLocalOpenIapFlavorStrategy: (contents: string, flavor: "play" | "horizon", language?: GradleLanguage) => string;
11
13
  declare const withLocalOpenIAP: ConfigPlugin<{
12
14
  localPath?: LocalPathOption;
13
15
  iosAlternativeBilling?: IOSAlternativeBillingConfig;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ensureLocalOpenIapFlavorStrategy = void 0;
36
37
  const config_plugins_1 = require("expo/config-plugins");
37
38
  const fs = __importStar(require("fs"));
38
39
  const path = __importStar(require("path"));
@@ -72,6 +73,42 @@ const logOnce = (() => {
72
73
  }
73
74
  };
74
75
  })();
76
+ const LOCAL_OPENIAP_FLAVOR_BLOCK_START = '// Added by expo-iap (local openiap-google flavor selection)';
77
+ const LOCAL_OPENIAP_FLAVOR_BLOCK_END = '// End expo-iap local openiap-google flavor selection';
78
+ const normalizeGradleLanguage = (language) => language === 'kotlin' ? 'kotlin' : 'groovy';
79
+ const ensureLocalOpenIapFlavorStrategy = (contents, flavor, language = 'groovy') => {
80
+ const existingBlockPattern = new RegExp(`\\n?${escapeRegExp(LOCAL_OPENIAP_FLAVOR_BLOCK_START)}[\\s\\S]*?${escapeRegExp(LOCAL_OPENIAP_FLAVOR_BLOCK_END)}\\n?`, 'gm');
81
+ const cleaned = contents
82
+ .replace(existingBlockPattern, '\n')
83
+ .replace(/\n{3,}/g, '\n\n')
84
+ .trimEnd();
85
+ const strategyBlock = language === 'kotlin'
86
+ ? `subprojects {
87
+ plugins.withId("com.android.library") {
88
+ extensions.configure<com.android.build.gradle.LibraryExtension>("android") {
89
+ defaultConfig {
90
+ missingDimensionStrategy("platform", "${flavor}")
91
+ }
92
+ }
93
+ }
94
+ }`
95
+ : `subprojects { subproject ->
96
+ subproject.plugins.withId("com.android.library") {
97
+ subproject.android {
98
+ defaultConfig {
99
+ missingDimensionStrategy "platform", "${flavor}"
100
+ }
101
+ }
102
+ }
103
+ }`;
104
+ return `${cleaned}
105
+
106
+ ${LOCAL_OPENIAP_FLAVOR_BLOCK_START}
107
+ ${strategyBlock}
108
+ ${LOCAL_OPENIAP_FLAVOR_BLOCK_END}
109
+ `;
110
+ };
111
+ exports.ensureLocalOpenIapFlavorStrategy = ensureLocalOpenIapFlavorStrategy;
75
112
  const withLocalOpenIAP = (config, props) => {
76
113
  // Import and apply iOS alternative billing configuration if provided
77
114
  if (props?.iosAlternativeBilling) {
@@ -162,9 +199,15 @@ const withLocalOpenIAP = (config, props) => {
162
199
  .replace(/\\/g, '/');
163
200
  // 1) settings.gradle: include and map projectDir
164
201
  const settings = config.modResults;
165
- const includeLine = "include ':openiap-google'";
166
- const projectDirLine = `project(':openiap-google').projectDir = new File(settingsDir, '${relativeAndroidModulePath}')`;
167
- const projectDirPattern = /^project\(':openiap-google'\)\.projectDir\s*=.*$/gm;
202
+ const settingsLanguage = normalizeGradleLanguage(settings.language);
203
+ const includeLine = settingsLanguage === 'kotlin'
204
+ ? 'include(":openiap-google")'
205
+ : "include ':openiap-google'";
206
+ const projectDirLine = settingsLanguage === 'kotlin'
207
+ ? `project(":openiap-google").projectDir = File(settingsDir, "${relativeAndroidModulePath}")`
208
+ : `project(':openiap-google').projectDir = new File(settingsDir, '${relativeAndroidModulePath}')`;
209
+ const includePattern = /include\s*(?:\(\s*)?["']:openiap-google["']\s*\)?/;
210
+ const projectDirPattern = /^\s*project\(["']:openiap-google["']\)\.projectDir\s*=.*$/gm;
168
211
  let contents = settings.contents ?? '';
169
212
  // Ensure pluginManagement has plugin mappings required by the included module
170
213
  const injectPluginManagement = () => {
@@ -208,7 +251,7 @@ const withLocalOpenIAP = (config, props) => {
208
251
  }
209
252
  };
210
253
  injectPluginManagement();
211
- if (!contents.includes(includeLine))
254
+ if (!includePattern.test(contents))
212
255
  contents += `\n${includeLine}\n`;
213
256
  if (projectDirPattern.test(contents)) {
214
257
  contents = contents.replace(projectDirPattern, projectDirLine);
@@ -232,9 +275,14 @@ const withLocalOpenIAP = (config, props) => {
232
275
  return config;
233
276
  }
234
277
  const gradle = config.modResults;
235
- const dependencyLine = ` implementation project(':openiap-google')`;
278
+ const appLanguage = normalizeGradleLanguage(gradle.language);
279
+ const dependencyLine = appLanguage === 'kotlin'
280
+ ? ` implementation(project(":openiap-google"))`
281
+ : ` implementation project(':openiap-google')`;
236
282
  const flavor = props?.isHorizonEnabled ? 'horizon' : 'play';
237
- const strategyLine = ` missingDimensionStrategy "platform", "${flavor}"`;
283
+ const strategyLine = appLanguage === 'kotlin'
284
+ ? ` missingDimensionStrategy("platform", "${flavor}")`
285
+ : ` missingDimensionStrategy "platform", "${flavor}"`;
238
286
  let contents = gradle.contents;
239
287
  // Remove Maven deps (both openiap-google and openiap-google-horizon)
240
288
  // to avoid duplicate classes with local module
@@ -273,6 +321,23 @@ const withLocalOpenIAP = (config, props) => {
273
321
  gradle.contents = contents;
274
322
  return config;
275
323
  });
324
+ // 2b) project build.gradle: Expo autolinked library modules can consume the
325
+ // local flavored OpenIAP module transitively, so give them the same default.
326
+ config = (0, config_plugins_1.withProjectBuildGradle)(config, (config) => {
327
+ const projectRoot = config.modRequest.projectRoot;
328
+ const raw = props?.localPath;
329
+ const androidInput = typeof raw === 'string' ? undefined : raw?.android;
330
+ const androidModulePath = resolveAndroidModulePath(androidInput) ||
331
+ resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) ||
332
+ null;
333
+ if (!androidModulePath || !fs.existsSync(androidModulePath)) {
334
+ return config;
335
+ }
336
+ const flavor = props?.isHorizonEnabled ? 'horizon' : 'play';
337
+ config.modResults.contents = (0, exports.ensureLocalOpenIapFlavorStrategy)(config.modResults.contents, flavor, normalizeGradleLanguage(config.modResults.language));
338
+ logOnce(`🛠️ expo-iap: Added local OpenIAP flavor strategy for ${flavor}`);
339
+ return config;
340
+ });
276
341
  // 3) Set horizonEnabled in gradle.properties
277
342
  config = (0, config_plugins_1.withDangerousMod)(config, [
278
343
  'android',
@@ -3,6 +3,7 @@ import {
3
3
  withDangerousMod,
4
4
  withSettingsGradle,
5
5
  withAppBuildGradle,
6
+ withProjectBuildGradle,
6
7
  } from 'expo/config-plugins';
7
8
  import * as fs from 'fs';
8
9
  import * as path from 'path';
@@ -16,6 +17,7 @@ import {
16
17
  * This is only for local development with openiap-apple library
17
18
  */
18
19
  type LocalPathOption = string | {ios?: string; android?: string};
20
+ type GradleLanguage = 'groovy' | 'kotlin';
19
21
 
20
22
  interface AndroidGradlePluginVersions {
21
23
  kotlin: string;
@@ -86,6 +88,59 @@ const logOnce = (() => {
86
88
  };
87
89
  })();
88
90
 
91
+ const LOCAL_OPENIAP_FLAVOR_BLOCK_START =
92
+ '// Added by expo-iap (local openiap-google flavor selection)';
93
+ const LOCAL_OPENIAP_FLAVOR_BLOCK_END =
94
+ '// End expo-iap local openiap-google flavor selection';
95
+
96
+ const normalizeGradleLanguage = (language?: string): GradleLanguage =>
97
+ language === 'kotlin' ? 'kotlin' : 'groovy';
98
+
99
+ export const ensureLocalOpenIapFlavorStrategy = (
100
+ contents: string,
101
+ flavor: 'play' | 'horizon',
102
+ language: GradleLanguage = 'groovy',
103
+ ): string => {
104
+ const existingBlockPattern = new RegExp(
105
+ `\\n?${escapeRegExp(
106
+ LOCAL_OPENIAP_FLAVOR_BLOCK_START,
107
+ )}[\\s\\S]*?${escapeRegExp(LOCAL_OPENIAP_FLAVOR_BLOCK_END)}\\n?`,
108
+ 'gm',
109
+ );
110
+ const cleaned = contents
111
+ .replace(existingBlockPattern, '\n')
112
+ .replace(/\n{3,}/g, '\n\n')
113
+ .trimEnd();
114
+
115
+ const strategyBlock =
116
+ language === 'kotlin'
117
+ ? `subprojects {
118
+ plugins.withId("com.android.library") {
119
+ extensions.configure<com.android.build.gradle.LibraryExtension>("android") {
120
+ defaultConfig {
121
+ missingDimensionStrategy("platform", "${flavor}")
122
+ }
123
+ }
124
+ }
125
+ }`
126
+ : `subprojects { subproject ->
127
+ subproject.plugins.withId("com.android.library") {
128
+ subproject.android {
129
+ defaultConfig {
130
+ missingDimensionStrategy "platform", "${flavor}"
131
+ }
132
+ }
133
+ }
134
+ }`;
135
+
136
+ return `${cleaned}
137
+
138
+ ${LOCAL_OPENIAP_FLAVOR_BLOCK_START}
139
+ ${strategyBlock}
140
+ ${LOCAL_OPENIAP_FLAVOR_BLOCK_END}
141
+ `;
142
+ };
143
+
89
144
  const withLocalOpenIAP: ConfigPlugin<
90
145
  {
91
146
  localPath?: LocalPathOption;
@@ -203,10 +258,18 @@ const withLocalOpenIAP: ConfigPlugin<
203
258
 
204
259
  // 1) settings.gradle: include and map projectDir
205
260
  const settings = config.modResults;
206
- const includeLine = "include ':openiap-google'";
207
- const projectDirLine = `project(':openiap-google').projectDir = new File(settingsDir, '${relativeAndroidModulePath}')`;
261
+ const settingsLanguage = normalizeGradleLanguage(settings.language);
262
+ const includeLine =
263
+ settingsLanguage === 'kotlin'
264
+ ? 'include(":openiap-google")'
265
+ : "include ':openiap-google'";
266
+ const projectDirLine =
267
+ settingsLanguage === 'kotlin'
268
+ ? `project(":openiap-google").projectDir = File(settingsDir, "${relativeAndroidModulePath}")`
269
+ : `project(':openiap-google').projectDir = new File(settingsDir, '${relativeAndroidModulePath}')`;
270
+ const includePattern = /include\s*(?:\(\s*)?["']:openiap-google["']\s*\)?/;
208
271
  const projectDirPattern =
209
- /^project\(':openiap-google'\)\.projectDir\s*=.*$/gm;
272
+ /^\s*project\(["']:openiap-google["']\)\.projectDir\s*=.*$/gm;
210
273
  let contents = settings.contents ?? '';
211
274
 
212
275
  // Ensure pluginManagement has plugin mappings required by the included module
@@ -279,7 +342,7 @@ const withLocalOpenIAP: ConfigPlugin<
279
342
  };
280
343
 
281
344
  injectPluginManagement();
282
- if (!contents.includes(includeLine)) contents += `\n${includeLine}\n`;
345
+ if (!includePattern.test(contents)) contents += `\n${includeLine}\n`;
283
346
  if (projectDirPattern.test(contents)) {
284
347
  contents = contents.replace(projectDirPattern, projectDirLine);
285
348
  } else if (!contents.includes(projectDirLine)) {
@@ -305,9 +368,16 @@ const withLocalOpenIAP: ConfigPlugin<
305
368
  }
306
369
 
307
370
  const gradle = config.modResults;
308
- const dependencyLine = ` implementation project(':openiap-google')`;
371
+ const appLanguage = normalizeGradleLanguage(gradle.language);
372
+ const dependencyLine =
373
+ appLanguage === 'kotlin'
374
+ ? ` implementation(project(":openiap-google"))`
375
+ : ` implementation project(':openiap-google')`;
309
376
  const flavor = props?.isHorizonEnabled ? 'horizon' : 'play';
310
- const strategyLine = ` missingDimensionStrategy "platform", "${flavor}"`;
377
+ const strategyLine =
378
+ appLanguage === 'kotlin'
379
+ ? ` missingDimensionStrategy("platform", "${flavor}")`
380
+ : ` missingDimensionStrategy "platform", "${flavor}"`;
311
381
 
312
382
  let contents = gradle.contents;
313
383
 
@@ -358,6 +428,31 @@ const withLocalOpenIAP: ConfigPlugin<
358
428
  return config;
359
429
  });
360
430
 
431
+ // 2b) project build.gradle: Expo autolinked library modules can consume the
432
+ // local flavored OpenIAP module transitively, so give them the same default.
433
+ config = withProjectBuildGradle(config, (config) => {
434
+ const projectRoot = (config.modRequest as any).projectRoot as string;
435
+ const raw = props?.localPath;
436
+ const androidInput = typeof raw === 'string' ? undefined : raw?.android;
437
+ const androidModulePath =
438
+ resolveAndroidModulePath(androidInput) ||
439
+ resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) ||
440
+ null;
441
+
442
+ if (!androidModulePath || !fs.existsSync(androidModulePath)) {
443
+ return config;
444
+ }
445
+
446
+ const flavor = props?.isHorizonEnabled ? 'horizon' : 'play';
447
+ config.modResults.contents = ensureLocalOpenIapFlavorStrategy(
448
+ config.modResults.contents,
449
+ flavor,
450
+ normalizeGradleLanguage(config.modResults.language),
451
+ );
452
+ logOnce(`🛠️ expo-iap: Added local OpenIAP flavor strategy for ${flavor}`);
453
+ return config;
454
+ });
455
+
361
456
  // 3) Set horizonEnabled in gradle.properties
362
457
  config = withDangerousMod(config, [
363
458
  'android',