@tinkerlandai/longplay-react-native 0.10.11 → 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.
@@ -7,19 +7,54 @@ import kotlinx.coroutines.Dispatchers
7
7
  import kotlinx.coroutines.SupervisorJob
8
8
  import kotlinx.coroutines.cancel
9
9
  import kotlinx.coroutines.withContext
10
- import kotlinx.coroutines.withTimeout
10
+ import kotlinx.coroutines.withTimeoutOrNull
11
11
  import org.json.JSONObject
12
12
  import java.util.UUID
13
13
 
14
+ /**
15
+ * Readiness and pending calls of one evaluator host, confined to the main thread. Stopping fails every waiter with
16
+ * its cause: `identity_changed` only for an identity change or close, otherwise the host's actual failure.
17
+ */
18
+ internal class IndianaFeatureCalls {
19
+ private val readiness = CompletableDeferred<Unit>()
20
+ private val pending = mutableMapOf<UUID, CompletableDeferred<Any?>>()
21
+ var failure: IndianaClientException? = null
22
+ private set
23
+ val ready: Boolean get() = readiness.isCompleted && failure == null
24
+ fun markReady() { failure?.let { throw it }; readiness.complete(Unit) }
25
+ /** Returns false when already stopped; the first cause wins. */
26
+ fun stop(cause: IndianaClientException): Boolean {
27
+ if (failure != null) return false
28
+ failure = cause; readiness.completeExceptionally(cause)
29
+ pending.values.forEach { it.completeExceptionally(cause) }; pending.clear(); return true
30
+ }
31
+ suspend fun call(send: (UUID) -> Unit): Any? {
32
+ failure?.let { throw it }
33
+ withTimeoutOrNull(45_000) { readiness.await() } ?: throw IndianaClientException("temporary_unavailable")
34
+ failure?.let { throw it }
35
+ val id = UUID.randomUUID(); val result = CompletableDeferred<Any?>(); pending[id] = result
36
+ try {
37
+ send(id)
38
+ val settled = withTimeoutOrNull(45_000) { arrayOf(result.await()) } ?: throw IndianaClientException("temporary_unavailable")
39
+ return settled[0]
40
+ } finally { pending.remove(id) }
41
+ }
42
+ fun settle(id: UUID, result: Any?, failed: Boolean) {
43
+ val response = pending.remove(id) ?: return
44
+ if (failed) response.completeExceptionally(IndianaClientException("invalid_response")) else response.complete(result)
45
+ }
46
+ }
47
+
14
48
  /** Customer-scoped GB evaluator using the packaged official engine, without JS credentials. */
15
49
  class IndianaFeatureClient internal constructor(private val activity: Activity, private val configuration: IndianaConfiguration, attributes: JSONObject, private val operation: suspend (IndianaCustomerOperation, JSONObject) -> JSONObject) {
16
50
  private val attributes = JSONObject(attributes.toString())
17
51
  private val work = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
18
- private val readiness = CompletableDeferred<Unit>()
19
- private val pending = mutableMapOf<UUID, CompletableDeferred<Any?>>()
52
+ private val calls = IndianaFeatureCalls()
20
53
  var onError: ((Exception) -> Unit)? = null
21
- private var stopped = false
22
- private val host = IndianaBundledWebView(activity, "Longplay Feature evaluator", work, ::dispatch) { error -> if (!readiness.isCompleted) close() else onError?.invoke(error) }
54
+ private val host = IndianaBundledWebView(activity, "Longplay Feature evaluator", work, ::dispatch) { error ->
55
+ // Before readiness a host failure ends this client with its actual cause rather than an identity change.
56
+ if (!calls.ready) stop(error as? IndianaClientException ?: IndianaClientException("renderer_failed")) else onError?.invoke(error)
57
+ }
23
58
  init { host.start() }
24
59
  suspend fun evalFeature(key: String): JSONObject {
25
60
  val result = call(JSONObject().put("method", "evalFeature").put("key", key)) as? JSONObject ?: throw IndianaClientException("invalid_response")
@@ -31,34 +66,23 @@ class IndianaFeatureClient internal constructor(private val activity: Activity,
31
66
  suspend fun setAttributes(attributes: JSONObject) { call(JSONObject().put("method", "setAttributes").put("attributes", JSONObject(attributes.toString()))) }
32
67
  suspend fun refresh() { call(JSONObject().put("method", "refresh")) }
33
68
  suspend fun flush() { call(JSONObject().put("method", "flush")) }
34
- fun close() { activity.runOnUiThread {
35
- if (!stopped) {
36
- stopped = true; host.destroy()
37
- val error = IndianaClientException("identity_changed")
38
- readiness.completeExceptionally(error); pending.values.forEach { it.completeExceptionally(error) }; pending.clear(); work.cancel()
39
- }
40
- } }
69
+ fun close() { activity.runOnUiThread { stop(IndianaClientException("identity_changed")) } }
70
+ private fun stop(cause: IndianaClientException) { if (calls.stop(cause)) { host.destroy(); work.cancel() } }
41
71
  private suspend fun call(input: JSONObject): Any? = withContext(Dispatchers.Main.immediate) {
42
- if (stopped) throw IndianaClientException("identity_changed")
43
- withTimeout(45_000) { readiness.await() }
44
- if (stopped) throw IndianaClientException("identity_changed")
45
- val id = UUID.randomUUID(); val result = CompletableDeferred<Any?>(); pending[id] = result
46
- try { host.send(JSONObject().put("event", "feature_call").put("id", id.toString()).put("input", input)); withTimeout(45_000) { result.await() } }
47
- finally { pending.remove(id) }
72
+ calls.call { id -> host.send(JSONObject().put("event", "feature_call").put("id", id.toString()).put("input", input)) }
48
73
  }
49
74
  private suspend fun dispatch(method: String, raw: Any?): Any? = when (method) {
50
75
  "ready" -> { host.send(JSONObject().put("event", "init").put("surface", "features").put("scope", JSONObject().put("projectId", configuration.scope.projectId.toString()).put("environment", configuration.scope.environment.value)).put("apiOrigin", configuration.apiOrigin).put("attributes", attributes)); null }
51
76
  "featureOperation" -> {
52
77
  val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
53
78
  val name = value.getString("operation")
54
- if (stopped || name !in setOf("getFeatures", "evaluateFeatures", "trackEvents")) throw IndianaClientException("invalid_command")
79
+ if (calls.failure != null || name !in setOf("getFeatures", "evaluateFeatures", "trackEvents")) throw IndianaClientException("invalid_command")
55
80
  operation(IndianaCustomerOperation.valueOf(name), value.getJSONObject("input"))
56
81
  }
57
- "featuresReady" -> { if (stopped) throw IndianaClientException("identity_changed"); readiness.complete(Unit); null }
82
+ "featuresReady" -> { calls.markReady(); null }
58
83
  "featureResult" -> {
59
84
  val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
60
- val response = pending.remove(UUID.fromString(value.getString("id")))
61
- if (value.has("error")) response?.completeExceptionally(IndianaClientException("invalid_response")) else response?.complete(value.opt("result")); null
85
+ calls.settle(UUID.fromString(value.getString("id")), value.opt("result"), value.has("error")); null
62
86
  }
63
87
  else -> throw IndianaClientException("invalid_command")
64
88
  }
@@ -73,10 +73,27 @@ data class GoogleStoreTerms(val productKind: String, val offerKind: String, val
73
73
  }
74
74
  sealed interface GoogleStoreResult {
75
75
  data class Purchased(val evidence: GooglePurchaseEvidence): GoogleStoreResult
76
+ /** The Play flow ended without a purchase: the user dismissed it, or Play refused the payment and showed why. */
76
77
  data object Canceled: GoogleStoreResult
77
78
  data class Failed(val code: String): GoogleStoreResult
78
79
  }
79
80
 
81
+ /**
82
+ * Classifies a Play purchase flow that ended without a purchase. A refused payment ends like a dismissed sheet, as
83
+ * StoreKit reports it after its own payment sheet. Only lost Play connections and internal errors, whose purchase
84
+ * state is unknown, report `store_unavailable`.
85
+ */
86
+ internal fun googleFlowEnded(responseCode: Int, subResponseCode: Int): GoogleStoreResult = when {
87
+ responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> GoogleStoreResult.Canceled
88
+ // Google documents BILLING_UNAVAILABLE in a purchase result as a user billing error, including a refused charge.
89
+ responseCode == BillingClient.BillingResponseCode.BILLING_UNAVAILABLE ||
90
+ subResponseCode == BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS ||
91
+ subResponseCode == BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE -> GoogleStoreResult.Canceled
92
+ responseCode == BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> GoogleStoreResult.Failed("product_unavailable")
93
+ responseCode in setOf(BillingClient.BillingResponseCode.DEVELOPER_ERROR, BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED, BillingClient.BillingResponseCode.ITEM_NOT_OWNED) -> GoogleStoreResult.Failed("purchase_not_started")
94
+ else -> GoogleStoreResult.Failed("store_unavailable")
95
+ }
96
+
80
97
  /** The backend owns acknowledge/consume. Purchase callbacks never grant customer access. */
81
98
  class GoogleStorefront(context: Context, private val onResult: (GoogleStoreResult) -> Unit) : IndianaGoogleStore {
82
99
  private val client = BillingClient.newBuilder(context.applicationContext)
@@ -86,8 +103,7 @@ class GoogleStorefront(context: Context, private val onResult: (GoogleStoreResul
86
103
  if (purchase.products.size != 1 || purchase.quantity != 1 || purchase.purchaseState !in listOf(Purchase.PurchaseState.PURCHASED, Purchase.PurchaseState.PENDING)) onResult(GoogleStoreResult.Failed("unsupported_purchase"))
87
104
  else onResult(GoogleStoreResult.Purchased(GooglePurchaseEvidence(purchase.products.single(),purchase.purchaseToken,purchase.purchaseState == Purchase.PurchaseState.PENDING,purchase.accountIdentifiers?.obfuscatedAccountId,purchase.accountIdentifiers?.obfuscatedProfileId)))
88
105
  }
89
- BillingClient.BillingResponseCode.USER_CANCELED -> onResult(GoogleStoreResult.Canceled)
90
- else -> onResult(GoogleStoreResult.Failed("store_unavailable"))
106
+ else -> onResult(googleFlowEnded(result.responseCode, result.onPurchasesUpdatedSubResponseCode))
91
107
  }
92
108
  }
93
109
  .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
@@ -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) }
@@ -2,5 +2,5 @@
2
2
  package ai.tinkerland.indiana
3
3
 
4
4
  object IndianaSDK {
5
- const val version = "0.10.11"
5
+ const val version = "0.10.13"
6
6
  }