@tinkerlandai/longplay-react-native 0.10.8 → 0.10.13

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 (35) hide show
  1. package/dist/index.cjs +2 -2
  2. package/dist/index.d.cts +26549 -12669
  3. package/dist/index.d.ts +26549 -12669
  4. package/dist/index.js +2 -2
  5. package/native-sdk/android/assets/indiana/index.html +42 -42
  6. package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +6 -2
  7. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +9 -3
  8. package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +46 -22
  9. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +47 -21
  10. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelProcess.kt +120 -0
  11. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +57 -16
  12. package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +18 -2
  13. package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +48 -20
  14. package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +36 -44
  15. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +82 -59
  16. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +1 -1
  17. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +3 -1
  18. package/native-sdk/android/java/ai/tinkerland/longplay/Longplay.kt +3 -0
  19. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -1
  20. package/native-sdk/ios/BundledWebView.swift +5 -1
  21. package/native-sdk/ios/CustomerSession.swift +7 -3
  22. package/native-sdk/ios/FeatureClient.swift +75 -29
  23. package/native-sdk/ios/FunnelConfigurationRelay.swift +1 -0
  24. package/native-sdk/ios/FunnelProcess.swift +148 -0
  25. package/native-sdk/ios/FunnelSession.swift +37 -9
  26. package/native-sdk/ios/HTTPTransport.swift +2 -0
  27. package/native-sdk/ios/IndianaClient.swift +25 -60
  28. package/native-sdk/ios/NativeReceiptRelay.swift +3 -3
  29. package/native-sdk/ios/PaywallSession.swift +5 -4
  30. package/native-sdk/ios/SDKVersion.swift +1 -1
  31. package/native-sdk/ios/WebView/index.html +42 -42
  32. package/native-sdk/ios/funnel-wire-schemas.json +1 -1
  33. package/package.json +1 -1
  34. package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +0 -81
  35. package/native-sdk/ios/NativePendingKeychain.swift +0 -52
@@ -52,7 +52,6 @@ class IndianaClient internal constructor(private val configuration: IndianaConfi
52
52
  private val closed = AtomicBoolean(false)
53
53
  private val storeRestorePending = java.util.concurrent.ConcurrentHashMap<String, Pair<UUID, String>>()
54
54
  private val recoveringNative = AtomicBoolean(false)
55
- private val nativeReceiptPending = java.util.concurrent.ConcurrentHashMap<UUID, Pair<UUID, String>>()
56
55
  fun identify(customerToken: String) = synchronized(identityLock) {
57
56
  if (!customerToken.matches(Regex("gg_ct_[A-Za-z0-9_-]{32,128}"))) throw IndianaClientException("unauthenticated")
58
57
  installIdentity(customerToken, null)
@@ -63,7 +62,7 @@ class IndianaClient internal constructor(private val configuration: IndianaConfi
63
62
  val next = Identity(customerToken, UUID.randomUUID())
64
63
  val invalidated = { invalidateSession(next.epoch) }
65
64
  if (session == null) http.identify(customerToken, invalidated) else http.identify(session, invalidated)
66
- pushObservationID = UUID.randomUUID(); nativeReceiptPending.clear(); storeRestorePending.clear(); identity.set(next); funnels.forEach { it.get()?.reset() }; funnels.clear(); paywalls.forEach { it.get()?.close() }; paywalls.clear()
65
+ pushObservationID = UUID.randomUUID(); storeRestorePending.clear(); identity.set(next); funnels.forEach { it.get()?.reset() }; funnels.clear(); paywalls.forEach { it.get()?.close() }; paywalls.clear()
67
66
  contents.forEach { it.get()?.close() }; contents.clear()
68
67
  featuresClients.forEach { it.get()?.close() }; featuresClients.clear(); schedulePush()
69
68
  }
@@ -83,7 +82,7 @@ class IndianaClient internal constructor(private val configuration: IndianaConfi
83
82
  private fun clearIdentity() {
84
83
  analyticsTimer?.cancel(); analyticsTimer = null; analyticsEvents.clear()
85
84
  try { pushIdentity?.reserve(detach = true); pushSynchronizationError = null } catch (_: Exception) { pushSynchronizationError = "protected_storage_unavailable" }
86
- pushObservationID = UUID.randomUUID(); pushBindingSequence = 0; nativeReceiptPending.clear(); storeRestorePending.clear(); identity.set(Identity(null, UUID.randomUUID())); funnels.forEach { it.get()?.reset() }; funnels.clear(); paywalls.forEach { it.get()?.close() }; paywalls.clear()
85
+ pushObservationID = UUID.randomUUID(); pushBindingSequence = 0; storeRestorePending.clear(); identity.set(Identity(null, UUID.randomUUID())); funnels.forEach { it.get()?.reset() }; funnels.clear(); paywalls.forEach { it.get()?.close() }; paywalls.clear()
87
86
  contents.forEach { it.get()?.close() }; contents.clear()
88
87
  featuresClients.forEach { it.get()?.close() }; featuresClients.clear(); schedulePush()
89
88
  }
@@ -94,44 +93,16 @@ class IndianaClient internal constructor(private val configuration: IndianaConfi
94
93
  http.identify(null)
95
94
  clearIdentity()
96
95
  }
97
- /** Relay attaches the server API key on your backend. No run credential is needed, even after run expiry. */
98
- suspend fun recoverNativeOrders(context: Context, relay: suspend (JSONObject) -> JSONObject): List<JSONObject> = recoverOrders(IndianaNativePendingKeystore(context, configuration.scope), GoogleStorefront(context) { }, relay)
99
- internal suspend fun recoverOrders(storage: IndianaNativePendingStorage, store: IndianaGoogleStore, relay: suspend (JSONObject) -> JSONObject): List<JSONObject> {
100
- checkOpen(); val actor = identity.get(); val token = actor.token ?: throw IndianaClientException("unauthenticated")
101
- if (configuration.scope.environment != IndianaEnvironment.SANDBOX || !recoveringNative.compareAndSet(false, true)) throw IndianaClientException("command_pending")
102
- try {
103
- store.connect(); val records = storage.orders()
104
- val purchases = store.scanPurchases(false) + store.scanPurchases(true)
105
- val responses = mutableListOf<JSONObject>()
106
- for (record in records) {
107
- checkOpen(); if (identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
108
- val receipt = purchases.singleOrNull { it.productId == record.productId && it.accountBinding == record.accountId && it.intentBinding == record.profileId }
109
- val command = nativeReceiptPending[record.orderId] ?: run {
110
- val requestId = UUID.randomUUID()
111
- if (receipt != null && receipt.purchaseToken.toByteArray().size > 16384) throw IndianaClientException("invalid_evidence")
112
- val evidence = receipt?.let { JSONObject().put("provider", "google").put("orderId", record.orderId.toString()).put("receiptId", record.receiptId.toString()).put("purchaseToken", it.purchaseToken) }
113
- requestId to JSONObject().put("operation", "native_receipt").put("requestId", requestId.toString()).put("customerToken", token).put("orderId", record.orderId.toString()).put("receiptId", record.receiptId.toString()).put("nativeEvidence", evidence ?: JSONObject.NULL).toString()
114
- }.also { nativeReceiptPending[record.orderId] = it }
115
- val credential = http.access()
116
- if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
117
- val outgoing = JSONObject(command.second).put("customerToken", credential)
118
- val result = try { relay(outgoing) } catch (error: IndianaClientException) {
119
- if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
120
- val status = error.code.removePrefix("request_failed_").toIntOrNull()
121
- if (status != null && status < 500 && status != 425) { nativeReceiptPending.remove(record.orderId); if (status in setOf(403,404)) continue }
122
- throw error
123
- }
124
- if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
125
- if (safe { scope(result.getJSONObject("scope")) } != configuration.scope || result.optString("requestId") != command.first.toString() || result.optString("orderId") != record.orderId.toString() || result.optString("receiptId") != record.receiptId.toString() || result.optString("orderStatus") !in setOf("created", "pending", "trialing", "paid", "cancelled", "failed") || result.opt("refunded") !is Boolean) throw IndianaClientException("invalid_response")
126
- result.optJSONObject("nativeCompletion")?.let { completion ->
127
- if (completion.optString("orderId") != record.orderId.toString() || completion.optString("receiptId") != record.receiptId.toString() || completion.opt("membership") !is Boolean) throw IndianaClientException("invalid_response")
128
- val ids = completion.getJSONArray("transactionIds"); if (ids.length() !in 1..50 || (0 until ids.length()).any { ids.opt(it) !is String || ids.getString(it).length !in 1..255 } || (0 until ids.length()).map { ids.getString(it) }.distinct().size != ids.length()) throw IndianaClientException("invalid_response")
129
- if (!completion.getBoolean("membership")) storage.remove(record.orderId)
130
- }
131
- nativeReceiptPending.remove(record.orderId); responses.add(JSONObject(result.toString()))
132
- }
133
- return responses
134
- } finally { store.close(); recoveringNative.set(false) }
96
+ /**
97
+ * Play's purchase list is the recovery source: every SDK purchase carries its order binding and Play lists it until
98
+ * the server consumes it or the subscription ends, so [restorePurchases] and a retried [purchaseSubscription] recover it.
99
+ * No local order journal exists; this compatibility entry relays nothing and returns no orders.
100
+ */
101
+ @Deprecated("Play's purchase list is the recovery source; call restorePurchases(context).")
102
+ @Suppress("UNUSED_PARAMETER")
103
+ suspend fun recoverNativeOrders(context: Context, relay: suspend (JSONObject) -> JSONObject): List<JSONObject> {
104
+ checkOpen(); if (identity.get().token == null) throw IndianaClientException("unauthenticated")
105
+ return emptyList()
135
106
  }
136
107
  /** Reinstall and device-change recovery uses the store's current transactions, never a local order journal. */
137
108
  suspend fun restorePurchases(context: Context): List<JSONObject> = restoreStorePurchases(GoogleStorefront(context) {})
@@ -145,10 +116,24 @@ class IndianaClient internal constructor(private val configuration: IndianaConfi
145
116
  if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
146
117
  val intent = purchase.intentBinding?.let { runCatching { UUID.fromString(it) }.getOrNull() }
147
118
  // Unbound historical purchases are not automatically transferred to a signed-in app account.
148
- intent?.let { submitStorePurchase(purchase,it,actor.epoch) }
119
+ intent?.let { restoreOne(purchase,it,actor.epoch) }
149
120
  }
150
121
  } finally { store.close(); recoveringNative.set(false) }
151
122
  }
123
+ /**
124
+ * The Play account can hold purchases bound to another Customer or scope. Their definite refusal (403/404) skips
125
+ * that purchase; any other refusal or unknown result ends the restore.
126
+ */
127
+ private suspend fun restoreOne(purchase: GooglePurchaseEvidence, intent: UUID, original: UUID): JSONObject? = try {
128
+ submitStorePurchase(purchase,intent,original)
129
+ } catch (error: IndianaClientException) {
130
+ if (identity.get().epoch != original || closed.get()) throw IndianaClientException("identity_changed")
131
+ val status = error.code.removePrefix("request_failed_").toIntOrNull()
132
+ // A definite refusal settles that request; a later attempt starts a new one.
133
+ if (status != null && status < 500 && status != 425) storeRestorePending.remove(purchase.purchaseToken)
134
+ if (status != 403 && status != 404) throw error
135
+ null
136
+ }
152
137
  private suspend fun submitStorePurchase(purchase: GooglePurchaseEvidence, intent: UUID, original: UUID): JSONObject {
153
138
  if (purchase.purchaseToken.toByteArray().size > 16384) throw IndianaClientException("invalid_evidence")
154
139
  val command = storeRestorePending[purchase.purchaseToken] ?: run {
@@ -165,7 +150,12 @@ class IndianaClient internal constructor(private val configuration: IndianaConfi
165
150
  }
166
151
  storeRestorePending.remove(purchase.purchaseToken); return result
167
152
  }
168
- /** Uses the fixed server instruction and actual Play quote; the server owns acknowledgement and grants. */
153
+ /**
154
+ * Uses the fixed server instruction and actual Play quote; the server owns acknowledgement and grants. A dismissed
155
+ * sheet or a payment Play refused ends the operation as cancelled. The wait for Play's result follows the user's
156
+ * sheet and has no deadline: cancel the calling coroutine to stop waiting. The operation then stays pending, and a
157
+ * retry with the same `operationId` first submits any purchase Play completed.
158
+ */
169
159
  suspend fun purchaseSubscription(activity: Activity, operationId: UUID, confirm: suspend (GoogleQuote) -> Boolean): JSONObject {
170
160
  if (!recoveringNative.compareAndSet(false,true)) throw IndianaClientException("command_pending")
171
161
  try { return purchaseSubscriptionInternal(activity,operationId,false,confirm) } finally { recoveringNative.set(false) }
@@ -330,7 +320,9 @@ class IndianaClient internal constructor(private val configuration: IndianaConfi
330
320
  /** The authenticated merchant backend selects the Customer and adds its Server Key; retain idempotencyKey on retry. */
331
321
  suspend fun trackUsage(command: JSONObject, relay: suspend (JSONObject) -> JSONObject): JSONObject {
332
322
  checkOpen(); val actor = identity.get(); if (actor.token == null) throw IndianaClientException("unauthenticated")
333
- val body = JSONObject(command.toString()); IndianaSchema.validate("usageCommand", body)
323
+ val body = JSONObject(command.toString())
324
+ try { IndianaSchema.validate("usageCommand", body) }
325
+ catch (_: Exception) { throw IndianaClientException("invalid_request") }
334
326
  val result = try { relay(body) } catch (error: Exception) {
335
327
  if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
336
328
  throw error
@@ -1,7 +1,6 @@
1
1
  package ai.tinkerland.indiana
2
2
 
3
3
  import kotlinx.coroutines.sync.Mutex
4
- import kotlinx.coroutines.sync.withLock
5
4
  import org.json.JSONObject
6
5
  import org.json.JSONArray
7
6
  import java.net.URI
@@ -20,6 +19,7 @@ class IndianaPaywallSession internal constructor(
20
19
  private val quoted = AtomicReference<JSONObject?>(null)
21
20
  private val purchased = AtomicReference<JSONObject?>(null)
22
21
  private val mutex = Mutex()
22
+ private val stateLock = Any()
23
23
  private var pendingPreview: String? = null
24
24
  @Volatile private var invalidation: (() -> Unit)? = null
25
25
  private val selector: JSONObject
@@ -29,8 +29,17 @@ class IndianaPaywallSession internal constructor(
29
29
  else JSONObject().put("type", "version").put("versionId", versionId.toString())
30
30
  }
31
31
  val current: JSONObject? get() = view.get()?.let { JSONObject(it.toString()) }
32
- fun close() { token.set(null); view.set(null); quoted.set(null); purchased.set(null); invalidation?.invoke(); invalidation = null }
33
- internal fun onInvalidation(callback: () -> Unit) { invalidation = callback; if (token.get() == null) callback() }
32
+ fun close() {
33
+ val callback = synchronized(stateLock) {
34
+ token.set(null); view.set(null); quoted.set(null); purchased.set(null); pendingPreview = null
35
+ val current = invalidation; invalidation = null; current
36
+ }
37
+ callback?.invoke()
38
+ }
39
+ internal fun onInvalidation(callback: () -> Unit) {
40
+ val closed = synchronized(stateLock) { if (token.get() == null) true else { invalidation = callback; false } }
41
+ if (closed) callback()
42
+ }
34
43
  internal fun hostConfiguration() = configuration
35
44
  internal fun hostInit() = JSONObject().put("event", "init").put("surface", "paywall")
36
45
  .put("scope", JSONObject().put("projectId", configuration.scope.projectId.toString()).put("environment", configuration.scope.environment.value))
@@ -51,64 +60,78 @@ class IndianaPaywallSession internal constructor(
51
60
  return result
52
61
  }
53
62
  suspend fun refresh(): JSONObject = hostCommand(JSONObject().put("operation", "resolve").put("input", JSONObject()))
54
- internal suspend fun hostCommand(payload: JSONObject): JSONObject = mutex.withLock {
55
- if (token.get() == null || payload.keys().asSequence().toSet() != setOf("operation", "input")) throw IndianaClientException("invalid_command")
56
- val input = payload.getJSONObject("input")
57
- when (val action = payload.getString("operation")) {
58
- "resolve" -> {
59
- if (input.length() != 0) throw IndianaClientException("invalid_command")
60
- val prior = view.get()
61
- val selected = prior?.let { JSONObject().put("type", "version").put("versionId", it.getString("id")) } ?: selector
62
- val result = send("resolvePaywall", "paywalls/resolve", JSONObject().put("selector", selected)
63
- .put("commerce", JSONObject().put("provider", provider.value).put("currency", currency)).put("locale", locale)).getJSONObject("paywall")
64
- val key = if (selector.getString("type") == "version") "id" else "paywallId"
65
- if (result.getString(key) != selector.getString(if (key == "id") "versionId" else key) ||
66
- prior != null && (result.getString("id") != prior.getString("id") || result.getJSONObject("offering").getString("id") != prior.getJSONObject("offering").getString("id") || result.getJSONObject("content").getJSONObject("artifact").getString("sha256") != prior.getJSONObject("content").getJSONObject("artifact").getString("sha256")) ||
67
- result.getJSONObject("document").getString("offeringVersionId") != result.getJSONObject("offering").getString("id")) throw IndianaClientException("invalid_response")
68
- view.set(result); JSONObject(result.toString())
69
- }
70
- "preview" -> {
71
- if (!setOf("actionId", "productVersionId", "requestId", "offerId", "nativeOffer", "featureQuantities").containsAll(input.keys().asSequence().toSet())) throw IndianaClientException("invalid_command")
72
- val product = input.getString("productVersionId")
73
- val policy = view.get()?.getJSONObject("document")?.getJSONObject("buttons")?.optJSONObject(input.getString("actionId")) ?: throw IndianaClientException("invalid_command")
74
- if (policy.optString("type") != "checkout") throw IndianaClientException("invalid_command")
75
- val selection = policy.getJSONObject("product")
76
- val allowed = if (selection.getString("type") == "fixed") selection.getString("productVersionId") == product
77
- else selection.getJSONArray("allowedProductVersionIds").let { ids -> (0 until ids.length()).any { ids.getString(it) == product } }
78
- if (!allowed) throw IndianaClientException("invalid_command")
79
- val fingerprint = canonical(payload)
80
- if (pendingPreview != null && pendingPreview != fingerprint) {
81
- val status = purchased.get()?.optString("status")
82
- if (status == null || status == "pending") throw IndianaClientException("command_pending")
83
- quoted.set(null); purchased.set(null)
63
+ internal suspend fun hostCommand(payload: JSONObject): JSONObject {
64
+ if (!mutex.tryLock()) throw IndianaClientException("command_pending")
65
+ try {
66
+ if (token.get() == null || payload.keys().asSequence().toSet() != setOf("operation", "input")) throw IndianaClientException("invalid_command")
67
+ val input = payload.getJSONObject("input")
68
+ return when (val action = payload.getString("operation")) {
69
+ "resolve" -> {
70
+ if (input.length() != 0) throw IndianaClientException("invalid_command")
71
+ val prior = view.get()
72
+ val selected = prior?.let { JSONObject().put("type", "version").put("versionId", it.getString("id")) } ?: selector
73
+ val result = send("resolvePaywall", "paywalls/resolve", JSONObject().put("selector", selected)
74
+ .put("commerce", JSONObject().put("provider", provider.value).put("currency", currency)).put("locale", locale)).getJSONObject("paywall")
75
+ val key = if (selector.getString("type") == "version") "id" else "paywallId"
76
+ if (result.getString(key) != selector.getString(if (key == "id") "versionId" else key) ||
77
+ prior != null && (result.getString("id") != prior.getString("id") || result.getJSONObject("offering").getString("id") != prior.getJSONObject("offering").getString("id") || result.getJSONObject("content").getJSONObject("artifact").getString("sha256") != prior.getJSONObject("content").getJSONObject("artifact").getString("sha256")) ||
78
+ result.getJSONObject("document").getString("offeringVersionId") != result.getJSONObject("offering").getString("id")) throw IndianaClientException("invalid_response")
79
+ synchronized(stateLock) {
80
+ if (token.get() == null) throw IndianaClientException("identity_changed")
81
+ view.set(result); JSONObject(result.toString())
82
+ }
84
83
  }
85
- if (pendingPreview == fingerprint) quoted.get()?.let { return@withLock JSONObject(it.toString()) }
86
- val selectedPlan = JSONObject().put("productVersionId", product)
87
- if (input.has("featureQuantities")) selectedPlan.put("featureQuantities", input.getJSONObject("featureQuantities"))
88
- val body = JSONObject().put("requestId", input.getString("requestId"))
89
- .put("plans", JSONArray().put(selectedPlan)).put("provider", provider.value).put("currency", currency)
90
- for (key in listOf("offerId", "nativeOffer")) if (input.has(key)) body.put(key, input.get(key))
91
- IndianaSchema.validate("previewAttachRequest", body)
92
- pendingPreview = fingerprint
93
- val result = send("previewAttach", "subscriptions/preview-attach", body).getJSONObject("quote")
94
- if (result.getString("provider") != provider.value || result.getString("currency") != currency || result.getJSONArray("lines").length() != 1 || result.getJSONArray("lines").getJSONObject(0).getString("productVersionId") != product) throw IndianaClientException("invalid_response")
95
- quoted.set(result); purchased.set(null); JSONObject(result.toString())
96
- }
97
- "confirm", "cancel", "read" -> {
98
- if (input.length() != 0) throw IndianaClientException("invalid_command")
99
- val id = quoted.get()?.getString("operationId") ?: throw IndianaClientException("invalid_command")
100
- val body = JSONObject().put("operationId", id)
101
- if (action != "read") body.put("intent", action)
102
- val result = send(if (action == "read") "readSubscriptionOperation" else "attach",
103
- if (action == "read") "subscriptions/operations/read" else "subscriptions/attach", body).getJSONObject("operation")
104
- if (result.getString("operationId") != id || result.getString("currency") != currency) throw IndianaClientException("invalid_response")
105
- purchased.get()?.takeIf { it.getString("status") == "complete" }?.let { return@withLock JSONObject(it.toString()) }
106
- purchased.set(result)
107
- if (result.getString("status") !in setOf("pending", "complete")) { pendingPreview = null; quoted.set(null) }
108
- JSONObject(result.toString())
84
+ "preview" -> {
85
+ if (!setOf("actionId", "productVersionId", "requestId", "offerId", "nativeOffer", "featureQuantities").containsAll(input.keys().asSequence().toSet())) throw IndianaClientException("invalid_command")
86
+ val product = input.getString("productVersionId")
87
+ val policy = view.get()?.getJSONObject("document")?.getJSONObject("buttons")?.optJSONObject(input.getString("actionId")) ?: throw IndianaClientException("invalid_command")
88
+ if (policy.optString("type") != "checkout") throw IndianaClientException("invalid_command")
89
+ val selection = policy.getJSONObject("product")
90
+ val allowed = if (selection.getString("type") == "fixed") selection.getString("productVersionId") == product
91
+ else selection.getJSONArray("allowedProductVersionIds").let { ids -> (0 until ids.length()).any { ids.getString(it) == product } }
92
+ if (!allowed) throw IndianaClientException("invalid_command")
93
+ val fingerprint = canonical(payload)
94
+ if (pendingPreview != null && pendingPreview != fingerprint) {
95
+ val status = purchased.get()?.optString("status")
96
+ if (status == null || status == "pending") throw IndianaClientException("command_pending")
97
+ }
98
+ if (pendingPreview == fingerprint) quoted.get()?.let { return JSONObject(it.toString()) }
99
+ val selectedPlan = JSONObject().put("productVersionId", product)
100
+ if (input.has("featureQuantities")) selectedPlan.put("featureQuantities", input.getJSONObject("featureQuantities"))
101
+ val body = JSONObject().put("requestId", input.getString("requestId"))
102
+ .put("plans", JSONArray().put(selectedPlan)).put("provider", provider.value).put("currency", currency)
103
+ for (key in listOf("offerId", "nativeOffer")) if (input.has(key)) body.put(key, input.get(key))
104
+ IndianaSchema.validate("previewAttachRequest", body)
105
+ synchronized(stateLock) {
106
+ if (token.get() == null) throw IndianaClientException("identity_changed")
107
+ if (pendingPreview != fingerprint) { quoted.set(null); purchased.set(null) }
108
+ pendingPreview = fingerprint
109
+ }
110
+ val result = send("previewAttach", "subscriptions/preview-attach", body).getJSONObject("quote")
111
+ if (result.getString("provider") != provider.value || result.getString("currency") != currency || result.getJSONArray("lines").length() != 1 || result.getJSONArray("lines").getJSONObject(0).getString("productVersionId") != product) throw IndianaClientException("invalid_response")
112
+ synchronized(stateLock) {
113
+ if (token.get() == null) throw IndianaClientException("identity_changed")
114
+ quoted.set(result); purchased.set(null); JSONObject(result.toString())
115
+ }
116
+ }
117
+ "confirm", "cancel", "read" -> {
118
+ if (input.length() != 0) throw IndianaClientException("invalid_command")
119
+ val id = quoted.get()?.getString("operationId") ?: throw IndianaClientException("invalid_command")
120
+ val body = JSONObject().put("operationId", id)
121
+ if (action != "read") body.put("intent", action)
122
+ val result = send(if (action == "read") "readSubscriptionOperation" else "attach",
123
+ if (action == "read") "subscriptions/operations/read" else "subscriptions/attach", body).getJSONObject("operation")
124
+ if (result.getString("operationId") != id || result.getString("currency") != currency) throw IndianaClientException("invalid_response")
125
+ synchronized(stateLock) {
126
+ if (token.get() == null) throw IndianaClientException("identity_changed")
127
+ purchased.get()?.takeIf { it.getString("status") == "complete" || it.getString("status") != "pending" && result.getString("status") == "pending" }?.let { return JSONObject(it.toString()) }
128
+ purchased.set(result)
129
+ JSONObject(result.toString())
130
+ }
131
+ }
132
+ else -> throw IndianaClientException("invalid_command")
109
133
  }
110
- else -> throw IndianaClientException("invalid_command")
111
- }
134
+ } finally { mutex.unlock() }
112
135
  }
113
136
  internal fun trustedPurchase(value: JSONObject): JSONObject {
114
137
  val saved = purchased.get() ?: throw IndianaClientException("invalid_command")
@@ -2,5 +2,5 @@
2
2
  package ai.tinkerland.indiana
3
3
 
4
4
  object IndianaSDK {
5
- const val version = "0.10.8"
5
+ const val version = "0.10.13"
6
6
  }
@@ -3,8 +3,10 @@ import org.json.JSONObject
3
3
  import org.json.JSONArray
4
4
  import java.net.URI
5
5
  import java.util.UUID
6
+ import java.util.concurrent.ConcurrentHashMap
6
7
  /** Generated contract interpreter for the native wire boundary; no business policy is evaluated here. */
7
8
  internal object IndianaSchema {
9
+ private val patterns = ConcurrentHashMap<String, Regex>()
8
10
  private val schemas: JSONObject by lazy { IndianaSchema::class.java.getResourceAsStream("/funnel-wire-schemas.json")?.bufferedReader(Charsets.UTF_8)?.use { JSONObject(it.readText()) } ?: throw IndianaClientException("invalid_response") }
9
11
  fun validate(name: String, value: JSONObject) { val root = schemas.getJSONObject(name);if (!valid(value, root, root, 0)) throw IndianaClientException("invalid_response") }
10
12
  fun customerResponse(name: String, value: JSONObject): JSONObject { val root = schemas.getJSONObject(name); return adapt(value, root, root, listOf(name), 0) as JSONObject }
@@ -47,7 +49,7 @@ internal object IndianaSchema {
47
49
  }
48
50
  if (value is String) {
49
51
  if (schema.has("minLength") && value.length < schema.getInt("minLength") || schema.has("maxLength") && value.length > schema.getInt("maxLength")) return false
50
- if (schema.has("pattern") && !Regex(schema.getString("pattern")).containsMatchIn(value)) return false
52
+ if (schema.has("pattern") && !patterns.getOrPut(schema.getString("pattern")) { Regex(schema.getString("pattern")) }.containsMatchIn(value)) return false
51
53
  try { when (schema.optString("format")) { "uuid" -> UUID.fromString(value);"date-time" -> utcMillis(value);"uri" -> if (URI(value).scheme == null) return false } } catch (_: Exception) { return false }
52
54
  }
53
55
  if (value is Number) {
@@ -30,3 +30,6 @@ typealias LongplayStoreException = ai.tinkerland.indiana.IndianaStoreException
30
30
  typealias LongplayStoreSubscriptions = ai.tinkerland.indiana.IndianaStoreSubscriptions
31
31
  typealias LongplaySubscriptionPlan = ai.tinkerland.indiana.IndianaSubscriptionPlan
32
32
  typealias LongplaySubscriptionProvider = ai.tinkerland.indiana.IndianaSubscriptionProvider
33
+
34
+ typealias LongplayFunnelProcess = ai.tinkerland.indiana.IndianaFunnelProcess
35
+ typealias LongplayFunnelProcessBridge = ai.tinkerland.indiana.IndianaFunnelProcessBridge