@tinkerlandai/longplay-react-native 0.10.11 → 0.10.14
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.
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +17 -2
- package/dist/index.d.ts +17 -2
- package/dist/index.js +2 -2
- package/native-sdk/android/assets/indiana/index.html +28 -28
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +46 -22
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +18 -2
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +33 -43
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +1 -1
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -1
- package/native-sdk/ios/FeatureClient.swift +75 -29
- package/native-sdk/ios/IndianaClient.swift +25 -60
- package/native-sdk/ios/NativeReceiptRelay.swift +0 -2
- package/native-sdk/ios/SDKVersion.swift +1 -1
- package/native-sdk/ios/WebView/index.html +28 -28
- package/native-sdk/ios/funnel-wire-schemas.json +1 -1
- package/package.json +1 -1
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +0 -81
- package/native-sdk/ios/NativePendingKeychain.swift +0 -52
|
@@ -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.
|
|
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
|
|
19
|
-
private val pending = mutableMapOf<UUID, CompletableDeferred<Any?>>()
|
|
52
|
+
private val calls = IndianaFeatureCalls()
|
|
20
53
|
var onError: ((Exception) -> Unit)? = null
|
|
21
|
-
private
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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" -> {
|
|
82
|
+
"featuresReady" -> { calls.markReady(); null }
|
|
58
83
|
"featureResult" -> {
|
|
59
84
|
val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
|
|
60
|
-
|
|
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
|
-
|
|
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();
|
|
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;
|
|
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
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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) }
|