expo-iap 4.4.0-rc.7 → 4.4.1

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +4 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +1 -1
  4. package/android/build.gradle +2 -0
  5. package/android/src/main/java/expo/modules/iap/ExpoIapHelper.kt +189 -115
  6. package/android/src/main/java/expo/modules/iap/ExpoIapLog.kt +22 -3
  7. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +224 -34
  8. package/build/index.d.ts +22 -20
  9. package/build/index.d.ts.map +1 -1
  10. package/build/index.js +118 -30
  11. package/build/index.js.map +1 -1
  12. package/build/index.kepler.d.ts +3 -0
  13. package/build/index.kepler.d.ts.map +1 -1
  14. package/build/index.kepler.js +3 -0
  15. package/build/index.kepler.js.map +1 -1
  16. package/build/modules/android.d.ts +35 -19
  17. package/build/modules/android.d.ts.map +1 -1
  18. package/build/modules/android.js +57 -6
  19. package/build/modules/android.js.map +1 -1
  20. package/build/modules/ios.d.ts +8 -8
  21. package/build/modules/ios.d.ts.map +1 -1
  22. package/build/modules/ios.js +29 -2
  23. package/build/modules/ios.js.map +1 -1
  24. package/build/types.d.ts +279 -59
  25. package/build/types.d.ts.map +1 -1
  26. package/build/types.js.map +1 -1
  27. package/build/useIAP.d.ts +23 -3
  28. package/build/useIAP.d.ts.map +1 -1
  29. package/build/useIAP.js +43 -7
  30. package/build/useIAP.js.map +1 -1
  31. package/build/utils/errorMapping.d.ts +27 -3
  32. package/build/utils/errorMapping.d.ts.map +1 -1
  33. package/build/utils/errorMapping.js +119 -43
  34. package/build/utils/errorMapping.js.map +1 -1
  35. package/build/vega-adapter.d.ts.map +1 -1
  36. package/build/vega-adapter.js +37 -4
  37. package/build/vega-adapter.js.map +1 -1
  38. package/ios/ExpoIapHelper.swift +22 -8
  39. package/ios/ExpoIapLog.swift +13 -1
  40. package/ios/ExpoIapModule.swift +26 -7
  41. package/ios/onside/OnsideIapModule.swift +9 -1
  42. package/openiap-versions.json +3 -3
  43. package/package.json +1 -1
  44. package/plugin/build/withIAP.js +16 -9
  45. package/plugin/src/withIAP.ts +20 -12
  46. package/src/index.kepler.ts +9 -0
  47. package/src/index.ts +182 -55
  48. package/src/modules/android.ts +105 -7
  49. package/src/modules/ios.ts +57 -25
  50. package/src/types.ts +294 -59
  51. package/src/useIAP.ts +102 -17
  52. package/src/utils/errorMapping.ts +177 -71
  53. package/src/vega-adapter.ts +77 -7
package/CHANGELOG.md CHANGED
@@ -1,3 +1,6 @@
1
1
  # CHANGELOG
2
2
 
3
- [Check release notes](https://github.com/hyochan/expo-iap/releases)
3
+ OpenIAP keeps detailed release history centralized to avoid package-local drift.
4
+
5
+ - Consolidated release notes: [openiap.dev/docs/updates/releases](https://openiap.dev/docs/updates/releases)
6
+ - Expo package releases: [hyodotdev/openiap/releases?q=expo-iap](https://github.com/hyodotdev/openiap/releases?q=expo-iap&expanded=true)
package/CLAUDE.md CHANGED
@@ -39,7 +39,7 @@
39
39
  Before committing any changes:
40
40
 
41
41
  1. Run `bun run lint` to ensure code quality
42
- 2. Run `bun run typecheck` to verify TypeScript types
42
+ 2. Run `bun run lint:tsc` to verify TypeScript types
43
43
  3. Run `bun run test` to verify all tests pass (Note: Use `bun run test`, not `bun test`)
44
44
  4. **IMPORTANT**: Run tests in the example directory as well:
45
45
  - `cd example && bun run test`
package/README.md CHANGED
@@ -85,7 +85,7 @@ Expo IAP conforms to the **[OpenIAP specification](https://openiap.dev)** — an
85
85
 
86
86
  - **Shared specification** — Common types, error codes, and purchase flows across all platforms
87
87
  - **Generated type-safe bindings** — Swift, Kotlin, Dart, and GDScript from a single GraphQL schema
88
- - **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 8.x)
88
+ - **Platform implementations** — [openiap-apple](https://github.com/hyodotdev/openiap/tree/main/packages/apple) (StoreKit 2) and [openiap-google](https://github.com/hyodotdev/openiap/tree/main/packages/google) (Play Billing 9.1.0)
89
89
  - **Verification profiles** — Standardized receipt validation and purchase verification patterns
90
90
 
91
91
  Other libraries built on OpenIAP: [react-native-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/react-native-iap) · [flutter_inapp_purchase](https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase) · [kmp-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap) · [godot-iap](https://github.com/hyodotdev/openiap/tree/main/libraries/godot-iap)
@@ -103,6 +103,8 @@ kotlin {
103
103
 
104
104
  dependencies {
105
105
  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7"
106
+ testImplementation "junit:junit:4.13.2"
107
+ testImplementation "org.json:json:20180813"
106
108
 
107
109
  // Use OpenIAP Google module only; avoid direct BillingClient dependency
108
110
  if (findProject(":openiap-google") != null) {
@@ -1,20 +1,46 @@
1
1
  package expo.modules.iap
2
2
 
3
3
  import dev.hyo.openiap.AndroidSubscriptionOfferInput
4
+ import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid
4
5
  import dev.hyo.openiap.OpenIapError
5
6
  import dev.hyo.openiap.OpenIapModule
6
7
  import dev.hyo.openiap.ProductQueryType
7
8
  import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid
8
9
  import dev.hyo.openiap.SubscriptionReplacementModeAndroid
10
+ import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener
11
+ import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
12
+ import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
13
+ import dev.hyo.openiap.listener.OpenIapSubscriptionBillingIssueListener
14
+ import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener
9
15
  import expo.modules.kotlin.Promise
10
16
  import expo.modules.kotlin.modules.Module
11
17
  import kotlinx.coroutines.CoroutineScope
12
18
  import kotlinx.coroutines.launch
19
+ import org.json.JSONObject
13
20
  import java.util.Locale
14
21
  import java.util.concurrent.ConcurrentLinkedQueue
15
22
 
16
23
  object ExpoIapHelper {
17
24
  private const val MAX_BUFFERED_EVENTS = 200
25
+ internal const val ERROR_ENVELOPE_PREFIX = "OPENIAP_ERROR_JSON:"
26
+
27
+ data class ListenerHandles(
28
+ val purchaseUpdate: OpenIapPurchaseUpdateListener,
29
+ val purchaseError: OpenIapPurchaseErrorListener,
30
+ val userChoiceBilling: OpenIapUserChoiceBillingListener,
31
+ val developerProvidedBilling: OpenIapDeveloperProvidedBillingListener,
32
+ val subscriptionBillingIssue: OpenIapSubscriptionBillingIssueListener,
33
+ )
34
+
35
+ internal fun serializeOpenIapError(error: OpenIapError): Map<String, Any?> =
36
+ error.toJSON() +
37
+ when (error) {
38
+ is OpenIapError.ProductNotFound -> mapOf("productId" to error.productId)
39
+ is OpenIapError.SkuNotFound -> mapOf("productId" to error.sku)
40
+ else -> emptyMap()
41
+ }
42
+
43
+ internal fun serializeErrorEnvelope(payload: Map<String, Any?>): String = ERROR_ENVELOPE_PREFIX + JSONObject(payload).toString()
18
44
 
19
45
  fun emitOrQueue(
20
46
  module: Module,
@@ -56,29 +82,31 @@ object ExpoIapHelper {
56
82
  // Support nested request.google / request.android structure
57
83
  // If the params contain a "request" key with nested platform-specific data,
58
84
  // extract and flatten it before parsing.
59
- val effective: Map<String, Any?> = run {
60
- val request = params["request"] as? Map<*, *>
61
- if (request != null) {
62
- val nested = (request["google"] as? Map<*, *>)
63
- ?: (request["android"] as? Map<*, *>)
64
- if (nested != null) {
65
- val flat = mutableMapOf<String, Any?>()
66
- // Carry over top-level fields like type, useAlternativeBilling
67
- for ((k, v) in params) {
68
- if (k != "request") flat[k] = v
69
- }
70
- // Overlay platform-specific fields
71
- for ((k, v) in nested) {
72
- if (k is String) flat[k] = v
85
+ val effective: Map<String, Any?> =
86
+ run {
87
+ val request = params["request"] as? Map<*, *>
88
+ if (request != null) {
89
+ val nested =
90
+ (request["google"] as? Map<*, *>)
91
+ ?: (request["android"] as? Map<*, *>)
92
+ if (nested != null) {
93
+ val flat = mutableMapOf<String, Any?>()
94
+ // Carry over top-level fields like type, useAlternativeBilling
95
+ for ((k, v) in params) {
96
+ if (k != "request") flat[k] = v
97
+ }
98
+ // Overlay platform-specific fields
99
+ for ((k, v) in nested) {
100
+ if (k is String) flat[k] = v
101
+ }
102
+ flat
103
+ } else {
104
+ params
73
105
  }
74
- flat
75
106
  } else {
76
107
  params
77
108
  }
78
- } else {
79
- params
80
109
  }
81
- }
82
110
 
83
111
  val type = effective["type"] as? String
84
112
  val skus: List<String> =
@@ -102,7 +130,17 @@ object ExpoIapHelper {
102
130
  }
103
131
  } ?: emptyList()
104
132
  val purchaseToken = effective["purchaseToken"] as? String
133
+ val originalExternalTransactionId = effective["originalExternalTransactionId"] as? String
105
134
  val replacementMode = effective["replacementMode"] as? Number
135
+ val developerBillingOption =
136
+ (effective["developerBillingOption"] as? Map<*, *>)?.let { optionMap ->
137
+ val json =
138
+ optionMap.entries
139
+ .mapNotNull { (key, value) ->
140
+ (key as? String)?.let { it to value }
141
+ }.toMap()
142
+ DeveloperBillingOptionParamsAndroid.fromJson(json)
143
+ }
106
144
  val subscriptionProductReplacementParams =
107
145
  (effective["subscriptionProductReplacementParams"] as? Map<*, *>)?.let { paramsMap ->
108
146
  val oldProductId = paramsMap["oldProductId"] as? String
@@ -116,7 +154,7 @@ object ExpoIapHelper {
116
154
  )
117
155
  }
118
156
  }
119
- // offerToken for one-time purchase discounts (Android 7.0+)
157
+ // offerToken for one-time purchase discounts (Android 8.0+)
120
158
  val offerToken = effective["offerToken"] as? String
121
159
 
122
160
  return RequestPurchaseParams(
@@ -128,6 +166,8 @@ object ExpoIapHelper {
128
166
  offerToken = offerToken,
129
167
  offerTokenArr = offerTokenArr,
130
168
  explicitSubscriptionOffers = explicitSubscriptionOffers,
169
+ developerBillingOption = developerBillingOption,
170
+ originalExternalTransactionId = originalExternalTransactionId,
131
171
  purchaseToken = purchaseToken,
132
172
  replacementMode = replacementMode,
133
173
  subscriptionProductReplacementParams = subscriptionProductReplacementParams,
@@ -151,10 +191,12 @@ object ExpoIapHelper {
151
191
  val obfuscatedAccountId: String?,
152
192
  val obfuscatedProfileId: String?,
153
193
  val isOfferPersonalized: Boolean,
154
- /** Offer token for one-time purchase discounts (Android 7.0+) */
194
+ /** Offer token for one-time purchase discounts (Android 8.0+) */
155
195
  val offerToken: String?,
156
196
  val offerTokenArr: List<String>,
157
197
  val explicitSubscriptionOffers: List<AndroidSubscriptionOfferInput>,
198
+ val developerBillingOption: DeveloperBillingOptionParamsAndroid?,
199
+ val originalExternalTransactionId: String?,
158
200
  val purchaseToken: String?,
159
201
  val replacementMode: Number?,
160
202
  val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid?,
@@ -226,56 +268,43 @@ object ExpoIapHelper {
226
268
  eventUserChoiceBilling: String,
227
269
  eventDeveloperProvidedBilling: String,
228
270
  eventSubscriptionBillingIssue: String,
229
- ) {
230
- openIap.addPurchaseUpdateListener { p ->
231
- runCatching {
232
- emitOrQueue(
233
- module,
234
- scope,
235
- connectionReady,
236
- pendingEvents,
237
- eventPurchaseUpdated,
238
- p.toJson(),
239
- )
240
- }.onFailure { error ->
241
- ExpoIapLog.failure("buffer/send PURCHASE_UPDATED", error)
242
- // Emit as purchase error so user knows something went wrong
243
- val errorPayload =
244
- mapOf(
245
- "code" to OpenIapError.PurchaseFailed.CODE,
246
- "message" to "Failed to process purchase update: ${error.message}",
247
- )
271
+ ): ListenerHandles {
272
+ val purchaseUpdateListener =
273
+ OpenIapPurchaseUpdateListener { p ->
248
274
  runCatching {
249
275
  emitOrQueue(
250
276
  module,
251
277
  scope,
252
278
  connectionReady,
253
279
  pendingEvents,
254
- eventPurchaseError,
255
- errorPayload,
280
+ eventPurchaseUpdated,
281
+ p.toJson(),
256
282
  )
257
- }.onFailure { ExpoIapLog.failure("send error event", it) }
283
+ }.onFailure { error ->
284
+ ExpoIapLog.failure("buffer/send PURCHASE_UPDATED", error)
285
+ // Emit as purchase error so user knows something went wrong
286
+ val errorPayload =
287
+ mapOf(
288
+ "code" to OpenIapError.PurchaseFailed.CODE,
289
+ "message" to "Failed to process purchase update: ${error.message}",
290
+ )
291
+ runCatching {
292
+ emitOrQueue(
293
+ module,
294
+ scope,
295
+ connectionReady,
296
+ pendingEvents,
297
+ eventPurchaseError,
298
+ errorPayload,
299
+ )
300
+ }.onFailure { ExpoIapLog.failure("send error event", it) }
301
+ }
258
302
  }
259
- }
260
- openIap.addPurchaseErrorListener { e ->
261
- val errorJson = e.toJSON()
262
- runCatching {
263
- emitOrQueue(
264
- module,
265
- scope,
266
- connectionReady,
267
- pendingEvents,
268
- eventPurchaseError,
269
- errorJson,
270
- )
271
- }.onFailure { error ->
272
- ExpoIapLog.failure("buffer/send PURCHASE_ERROR", error)
273
- // Critical: if we can't emit the original error, at least try to emit a generic one
274
- val fallbackPayload =
275
- mapOf(
276
- "code" to OpenIapError.UnknownError.CODE,
277
- "message" to "Failed to emit purchase error: ${error.message}",
278
- )
303
+ openIap.addPurchaseUpdateListener(purchaseUpdateListener)
304
+
305
+ val purchaseErrorListener =
306
+ OpenIapPurchaseErrorListener { e ->
307
+ val errorJson = serializeOpenIapError(e)
279
308
  runCatching {
280
309
  emitOrQueue(
281
310
  module,
@@ -283,63 +312,108 @@ object ExpoIapHelper {
283
312
  connectionReady,
284
313
  pendingEvents,
285
314
  eventPurchaseError,
286
- fallbackPayload,
315
+ errorJson,
287
316
  )
288
- }.onFailure { ExpoIapLog.failure("send fallback error event", it) }
317
+ }.onFailure { error ->
318
+ ExpoIapLog.failure("buffer/send PURCHASE_ERROR", error)
319
+ // Critical: if we can't emit the original error, at least try to emit a generic one
320
+ val fallbackPayload =
321
+ mapOf(
322
+ "code" to OpenIapError.UnknownError.CODE,
323
+ "message" to "Failed to emit purchase error: ${error.message}",
324
+ )
325
+ runCatching {
326
+ emitOrQueue(
327
+ module,
328
+ scope,
329
+ connectionReady,
330
+ pendingEvents,
331
+ eventPurchaseError,
332
+ fallbackPayload,
333
+ )
334
+ }.onFailure { ExpoIapLog.failure("send fallback error event", it) }
335
+ }
336
+ // Also reject any pending purchase promises to match iOS behavior
337
+ val errorCode = errorJson["code"] as? String ?: OpenIapError.PurchaseFailed.CODE
338
+ rejectPurchasePromises(
339
+ errorCode,
340
+ serializeErrorEnvelope(errorJson),
341
+ null,
342
+ )
289
343
  }
290
- // Also reject any pending purchase promises to match iOS behavior
291
- val errorCode = errorJson["code"] as? String ?: OpenIapError.PurchaseFailed.CODE
292
- val errorMessage = errorJson["message"] as? String ?: "Purchase failed"
293
- rejectPurchasePromises(errorCode, errorMessage, null)
294
- }
295
- openIap.addUserChoiceBillingListener { details ->
296
- safeEmitEvent(
297
- module,
298
- scope,
299
- connectionReady,
300
- pendingEvents,
301
- eventUserChoiceBilling,
302
- details.toJson(),
303
- eventPurchaseError,
304
- "alternative-billing-not-available",
305
- "Failed to process user choice billing",
306
- "USER_CHOICE_BILLING",
307
- )
308
- }
309
- // Developer Provided Billing listener for External Payments (8.3.0+)
310
- openIap.addDeveloperProvidedBillingListener { details ->
311
- safeEmitEvent(
312
- module,
313
- scope,
314
- connectionReady,
315
- pendingEvents,
316
- eventDeveloperProvidedBilling,
317
- details.toJson(),
318
- eventPurchaseError,
319
- "developer-billing-error",
320
- "Failed to process developer provided billing",
321
- "DEVELOPER_PROVIDED_BILLING",
322
- )
323
- }
344
+ openIap.addPurchaseErrorListener(purchaseErrorListener)
345
+
346
+ val userChoiceBillingListener =
347
+ OpenIapUserChoiceBillingListener { details ->
348
+ safeEmitEvent(
349
+ module,
350
+ scope,
351
+ connectionReady,
352
+ pendingEvents,
353
+ eventUserChoiceBilling,
354
+ details.toJson(),
355
+ eventPurchaseError,
356
+ "alternative-billing-not-available",
357
+ "Failed to process user choice billing",
358
+ "USER_CHOICE_BILLING",
359
+ )
360
+ }
361
+ openIap.addUserChoiceBillingListener(userChoiceBillingListener)
362
+
363
+ // Developer Provided Billing listener for External Payments (8.3.0+) and Billing Choice (9.1.0+)
364
+ val developerProvidedBillingListener =
365
+ OpenIapDeveloperProvidedBillingListener { details ->
366
+ safeEmitEvent(
367
+ module,
368
+ scope,
369
+ connectionReady,
370
+ pendingEvents,
371
+ eventDeveloperProvidedBilling,
372
+ details.toJson(),
373
+ eventPurchaseError,
374
+ "developer-billing-error",
375
+ "Failed to process developer provided billing",
376
+ "DEVELOPER_PROVIDED_BILLING",
377
+ )
378
+ }
379
+ openIap.addDeveloperProvidedBillingListener(developerProvidedBillingListener)
380
+
324
381
  // Subscription billing-issue listener (Play Billing 8.1+ isSuspended; no-op on Horizon)
325
- openIap.addSubscriptionBillingIssueListener { purchase ->
326
- safeEmitEvent(
327
- module,
328
- scope,
329
- connectionReady,
330
- pendingEvents,
331
- eventSubscriptionBillingIssue,
332
- purchase.toJson(),
333
- eventPurchaseError,
334
- "subscription-billing-issue-error",
335
- "Failed to process subscription billing issue",
336
- "SUBSCRIPTION_BILLING_ISSUE",
337
- )
338
- }
382
+ val subscriptionBillingIssueListener =
383
+ OpenIapSubscriptionBillingIssueListener { purchase ->
384
+ safeEmitEvent(
385
+ module,
386
+ scope,
387
+ connectionReady,
388
+ pendingEvents,
389
+ eventSubscriptionBillingIssue,
390
+ purchase.toJson(),
391
+ eventPurchaseError,
392
+ "subscription-billing-issue-error",
393
+ "Failed to process subscription billing issue",
394
+ "SUBSCRIPTION_BILLING_ISSUE",
395
+ )
396
+ }
397
+ openIap.addSubscriptionBillingIssueListener(subscriptionBillingIssueListener)
398
+
399
+ return ListenerHandles(
400
+ purchaseUpdate = purchaseUpdateListener,
401
+ purchaseError = purchaseErrorListener,
402
+ userChoiceBilling = userChoiceBillingListener,
403
+ developerProvidedBilling = developerProvidedBillingListener,
404
+ subscriptionBillingIssue = subscriptionBillingIssueListener,
405
+ )
339
406
  }
340
407
 
341
- fun cleanupListeners(openIap: OpenIapModule) {
342
- // Android doesn't have explicit listeners to clean up
343
- // This function is kept for API compatibility with iOS
408
+ fun cleanupListeners(
409
+ openIap: OpenIapModule,
410
+ handles: ListenerHandles?,
411
+ ) {
412
+ if (handles == null) return
413
+ openIap.removePurchaseUpdateListener(handles.purchaseUpdate)
414
+ openIap.removePurchaseErrorListener(handles.purchaseError)
415
+ openIap.removeUserChoiceBillingListener(handles.userChoiceBilling)
416
+ openIap.removeDeveloperProvidedBillingListener(handles.developerProvidedBilling)
417
+ openIap.removeSubscriptionBillingIssueListener(handles.subscriptionBillingIssue)
344
418
  }
345
419
  }
@@ -12,7 +12,9 @@ internal object ExpoIapLog {
12
12
  "apikey",
13
13
  "secret",
14
14
  "jws",
15
- "receiptid",
15
+ "receipt",
16
+ "dataandroid",
17
+ "signatureandroid",
16
18
  "userid",
17
19
  "password",
18
20
  "bearer"
@@ -54,7 +56,7 @@ internal object ExpoIapLog {
54
56
  }
55
57
  }
56
58
 
57
- private fun stringify(value: Any?): String {
59
+ internal fun stringify(value: Any?): String {
58
60
  val sanitized = sanitize(value) ?: return "null"
59
61
  return when (sanitized) {
60
62
  is String -> sanitized
@@ -66,9 +68,14 @@ internal object ExpoIapLog {
66
68
  }
67
69
 
68
70
  private fun sanitize(value: Any?): Any? {
69
- if (value == null) return null
71
+ if (value == null || value === JSONObject.NULL) return null
70
72
 
71
73
  return when (value) {
74
+ is String -> sanitizeJsonString(value)
75
+ is JSONObject -> sanitizeMap(
76
+ value.keys().asSequence().associateWith(value::opt),
77
+ )
78
+ is JSONArray -> (0 until value.length()).mapNotNull { sanitize(value.opt(it)) }
72
79
  is Map<*, *> -> sanitizeMap(value)
73
80
  is List<*> -> value.mapNotNull { sanitize(it) }
74
81
  is Array<*> -> value.mapNotNull { sanitize(it) }
@@ -76,6 +83,18 @@ internal object ExpoIapLog {
76
83
  }
77
84
  }
78
85
 
86
+ private fun sanitizeJsonString(value: String): Any {
87
+ val trimmed = value.trim()
88
+ val json = runCatching {
89
+ when (trimmed.firstOrNull()) {
90
+ '{' -> JSONObject(trimmed)
91
+ '[' -> JSONArray(trimmed)
92
+ else -> null
93
+ }
94
+ }.getOrNull() ?: return value
95
+ return sanitize(json) ?: value
96
+ }
97
+
79
98
  private fun sanitizeMap(source: Map<*, *>): Map<String, Any?> {
80
99
  fun isSensitiveKey(key: String): Boolean {
81
100
  val normalized = key.lowercase(Locale.ROOT).filter { it.isLetterOrDigit() }