@tinkerlandai/indiana-react-native 0.9.1 → 0.9.5

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 (83) hide show
  1. package/IndianaReactNative.podspec +19 -0
  2. package/README.md +61 -4
  3. package/THIRD_PARTY_NOTICES.md +74 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
  7. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
  8. package/dist/index.cjs +17 -1
  9. package/dist/index.d.cts +57408 -604
  10. package/dist/index.d.ts +57408 -604
  11. package/dist/index.js +17 -1
  12. package/ios/IndianaBridge.m +11 -0
  13. package/ios/IndianaBridge.swift +275 -0
  14. package/native-sdk/android/assets/indiana/index.html +268 -0
  15. package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
  16. package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
  17. package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
  18. package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
  19. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
  20. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +193 -0
  21. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
  22. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
  23. package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
  24. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
  25. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
  26. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
  27. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
  28. package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
  29. package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
  30. package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
  31. package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
  32. package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
  33. package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
  34. package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
  35. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
  36. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
  37. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
  38. package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
  39. package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
  40. package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
  41. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
  42. package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
  43. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
  44. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
  45. package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
  46. package/native-sdk/android/resources/renderer-data-url.txt +1 -0
  47. package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
  48. package/native-sdk/ios/AppleStorefront.swift +218 -0
  49. package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
  50. package/native-sdk/ios/BundledWebView.swift +87 -0
  51. package/native-sdk/ios/ContentSession.swift +48 -0
  52. package/native-sdk/ios/ContentView.swift +51 -0
  53. package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
  54. package/native-sdk/ios/CustomerResponseSemantics.swift +232 -0
  55. package/native-sdk/ios/CustomerSemantics.swift +145 -0
  56. package/native-sdk/ios/CustomerSession.swift +129 -0
  57. package/native-sdk/ios/FeatureClient.swift +86 -0
  58. package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
  59. package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
  60. package/native-sdk/ios/FunnelSession.swift +232 -0
  61. package/native-sdk/ios/FunnelView.swift +125 -0
  62. package/native-sdk/ios/GeneratedProtocol.swift +16 -0
  63. package/native-sdk/ios/HTTPTransport.swift +74 -0
  64. package/native-sdk/ios/IndianaClient.swift +601 -0
  65. package/native-sdk/ios/NativePendingKeychain.swift +52 -0
  66. package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
  67. package/native-sdk/ios/NativeStoreOffer.swift +34 -0
  68. package/native-sdk/ios/PaywallContent.swift +75 -0
  69. package/native-sdk/ios/PaywallSession.swift +124 -0
  70. package/native-sdk/ios/PaywallView.swift +87 -0
  71. package/native-sdk/ios/Protocol.swift +49 -0
  72. package/native-sdk/ios/PushInstallation.swift +154 -0
  73. package/native-sdk/ios/RendererNavigation.swift +8 -0
  74. package/native-sdk/ios/Resources.swift +15 -0
  75. package/native-sdk/ios/SDKVersion.swift +4 -0
  76. package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
  77. package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
  78. package/native-sdk/ios/WebView/index.html +268 -0
  79. package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
  80. package/native-sdk/ios/WireSchema.swift +107 -0
  81. package/native-sdk/ios/funnel-wire-schemas.json +1 -0
  82. package/package.json +8 -3
  83. package/react-native.config.cjs +12 -0
@@ -0,0 +1,526 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import org.json.JSONObject
6
+ import java.util.UUID
7
+ import java.time.Instant
8
+ import java.lang.ref.WeakReference
9
+ import java.util.concurrent.atomic.AtomicReference
10
+ import java.util.concurrent.atomic.AtomicBoolean
11
+ import org.json.JSONArray
12
+ import kotlinx.coroutines.CompletableDeferred
13
+ import kotlinx.coroutines.CoroutineScope
14
+ import kotlinx.coroutines.SupervisorJob
15
+ import kotlinx.coroutines.Dispatchers
16
+ import kotlinx.coroutines.launch
17
+ import kotlinx.coroutines.cancel
18
+ import kotlinx.coroutines.sync.Mutex
19
+ import kotlinx.coroutines.sync.withLock
20
+
21
+ enum class IndianaSubscriptionProvider(val value: String) { STRIPE("stripe"), APPLE("apple"), GOOGLE("google") }
22
+ data class IndianaSubscriptionPlan(val productVersionId: UUID, val seatQuantities: Map<String, Int> = emptyMap(), val featureQuantities: Map<UUID, Int> = emptyMap())
23
+
24
+ interface IndianaGoogleStore: AutoCloseable {
25
+ suspend fun connect()
26
+ suspend fun quote(productId: String, basePlanId: String?, offerId: String?): GoogleQuote
27
+ suspend fun purchase(activity: Activity, productId: String, basePlanId: String?, offerId: String?, obfuscatedAccountId: String, obfuscatedProfileId: String, terms: GoogleStoreTerms)
28
+ suspend fun scanPurchases(membership: Boolean): List<GooglePurchaseEvidence>
29
+ }
30
+ /** Current customer session and independent subscription operations. */
31
+ class IndianaClient internal constructor(private val configuration: IndianaConfiguration, http: IndianaHTTP, private val funnelStorage: ((String) -> IndianaFunnelCommandStorage)? = null, private val publicPaywallContent: (suspend (JSONObject) -> JSONObject)? = null, private val pushStorage: (() -> IndianaPushStorage)? = null, sessionNow: () -> Instant = Instant::now): AutoCloseable {
32
+ constructor(context: Context, configuration: IndianaConfiguration): this(configuration, IndianaURLTransport(configuration), { token -> IndianaFunnelCommandKeystore(context.applicationContext,configuration,token) }, null, { IndianaPushKeystore(context.applicationContext, configuration.scope) })
33
+ private val http = IndianaSessionTransport(http, configuration.scope, sessionNow)
34
+ private data class Identity(val token: String?, val epoch: UUID)
35
+ private val identity = AtomicReference(Identity(null, UUID.randomUUID()))
36
+ private val identityLock = Any()
37
+ private var pushIdentity: IndianaPushIdentity? = null
38
+ private var pushBindingSequence = 0L
39
+ private val pushClientID = UUID.randomUUID()
40
+ private var pushObservationID = UUID.randomUUID()
41
+ private val pushScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
42
+ private val pushSyncLock = Mutex()
43
+ @Volatile var pushSynchronizationError: String? = null
44
+ private set
45
+ private val funnels = mutableListOf<WeakReference<IndianaFunnelSession>>()
46
+ private val paywalls = mutableListOf<WeakReference<IndianaPaywallSession>>()
47
+ private val contents = mutableListOf<WeakReference<IndianaContentSession>>()
48
+ private val featuresClients = mutableListOf<WeakReference<IndianaFeatureClient>>()
49
+ private val analyticsEvents = mutableListOf<JSONObject>()
50
+ private val analyticsFlushLock = Mutex()
51
+ private var analyticsTimer: kotlinx.coroutines.Job? = null
52
+ private val closed = AtomicBoolean(false)
53
+ private val storeRestorePending = java.util.concurrent.ConcurrentHashMap<String, Pair<UUID, String>>()
54
+ private val recoveringNative = AtomicBoolean(false)
55
+ private val nativeReceiptPending = java.util.concurrent.ConcurrentHashMap<UUID, Pair<UUID, String>>()
56
+ fun identify(customerToken: String) = synchronized(identityLock) {
57
+ if (!customerToken.matches(Regex("gg_ct_[A-Za-z0-9_-]{32,128}"))) throw IndianaClientException("unauthenticated")
58
+ installIdentity(customerToken, null)
59
+ }
60
+ private fun installIdentity(customerToken: String, session: IndianaCustomerSession?) {
61
+ checkOpen(); pushIdentity?.let { pushBindingSequence = it.reserve(); pushSynchronizationError = null }
62
+ analyticsTimer?.cancel(); analyticsTimer = null; analyticsEvents.clear()
63
+ val next = Identity(customerToken, UUID.randomUUID())
64
+ val invalidated = { invalidateSession(next.epoch) }
65
+ 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()
67
+ contents.forEach { it.get()?.close() }; contents.clear()
68
+ featuresClients.forEach { it.get()?.close() }; featuresClients.clear(); schedulePush()
69
+ }
70
+ fun identify(session: IndianaCustomerSession) = synchronized(identityLock) { session.validate(); installIdentity(session.accessToken, session) }
71
+ suspend fun refreshSession(): IndianaCustomerSession = http.refresh()
72
+ /** A host-owned session supplies Access Tokens; its Refresh Token never crosses the bridge. */
73
+ fun setAccessTokenProvider(provider: (suspend () -> String)?) { http.setAccessTokenProvider(provider) }
74
+ @JvmOverloads fun funnel(runToken: String, commandStorage: IndianaFunnelCommandStorage? = null): IndianaFunnelSession = synchronized(identityLock) {
75
+ checkOpen(); val session = IndianaFunnelSession(configuration, http, runToken, commandStorage ?: funnelStorage?.invoke(runToken))
76
+ identity.get().token?.let { session.identify(it) }; funnels.removeAll { it.get() == null }; funnels.add(WeakReference(session)); session
77
+ }
78
+ @JvmOverloads fun paywall(paywallId: UUID? = null, paywallVersionId: UUID? = null, provider: IndianaSubscriptionProvider, currency: String, locale: String = "en"): IndianaPaywallSession = synchronized(identityLock) {
79
+ checkOpen(); val token = identity.get().token ?: throw IndianaClientException("unauthenticated")
80
+ val session = IndianaPaywallSession(configuration, http, token, paywallId, paywallVersionId, provider, currency, locale, publicPaywallContent ?: IndianaPaywallContent(configuration)::resolve)
81
+ paywalls.removeAll { it.get() == null }; paywalls.add(WeakReference(session)); session
82
+ }
83
+ private fun clearIdentity() {
84
+ analyticsTimer?.cancel(); analyticsTimer = null; analyticsEvents.clear()
85
+ 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()
87
+ contents.forEach { it.get()?.close() }; contents.clear()
88
+ featuresClients.forEach { it.get()?.close() }; featuresClients.clear(); schedulePush()
89
+ }
90
+ private fun invalidateSession(epoch: UUID) = synchronized(identityLock) {
91
+ if (identity.get().epoch == epoch) clearIdentity()
92
+ }
93
+ fun reset() = synchronized(identityLock) {
94
+ http.identify(null)
95
+ clearIdentity()
96
+ }
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) }
135
+ }
136
+ /** Reinstall and device-change recovery uses the store's current transactions, never a local order journal. */
137
+ suspend fun restorePurchases(context: Context): List<JSONObject> = restoreStorePurchases(GoogleStorefront(context) {})
138
+ internal suspend fun restoreStorePurchases(store: IndianaGoogleStore): List<JSONObject> {
139
+ checkOpen(); val actor = identity.get(); if (actor.token == null) throw IndianaClientException("unauthenticated")
140
+ if (!recoveringNative.compareAndSet(false,true)) throw IndianaClientException("command_pending")
141
+ try {
142
+ store.connect(); val purchases = store.scanPurchases(false) + store.scanPurchases(true)
143
+ if (purchases.size > 100) throw IndianaClientException("invalid_response")
144
+ return purchases.mapNotNull { purchase ->
145
+ if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
146
+ val intent = purchase.intentBinding?.let { runCatching { UUID.fromString(it) }.getOrNull() }
147
+ // Unbound historical purchases are not automatically transferred to a signed-in app account.
148
+ intent?.let { submitStorePurchase(purchase,it,actor.epoch) }
149
+ }
150
+ } finally { store.close(); recoveringNative.set(false) }
151
+ }
152
+ private suspend fun submitStorePurchase(purchase: GooglePurchaseEvidence, intent: UUID, original: UUID): JSONObject {
153
+ if (purchase.purchaseToken.toByteArray().size > 16384) throw IndianaClientException("invalid_evidence")
154
+ val command = storeRestorePending[purchase.purchaseToken] ?: run {
155
+ val id = UUID.randomUUID()
156
+ id to JSONObject().put("requestId",id.toString()).put("provider","google").put("purchaseIntentId",intent.toString()).put("productId",purchase.productId).put("purchaseToken",purchase.purchaseToken).toString()
157
+ }.also { storeRestorePending[purchase.purchaseToken] = it }
158
+ val result = authenticated("subscriptions/native/restore",JSONObject(command.second))
159
+ if (identity.get().epoch != original || closed.get()) throw IndianaClientException("identity_changed")
160
+ val operation = result.optJSONObject("operation") ?: throw IndianaClientException("invalid_response")
161
+ if (operation.optString("requestId") != command.first.toString() || operation.optString("purchaseIntentId") != intent.toString() || runCatching { UUID.fromString(operation.getString("orderId")) }.isFailure || operation.optString("status") !in setOf("created","pending","trialing","paid","cancelled","failed") || operation.opt("refunded") !is Boolean || !operation.has("completion")) throw IndianaClientException("invalid_response")
162
+ operation.optJSONObject("completion")?.let { completion ->
163
+ val ids = completion.optJSONArray("transactionIds") ?: throw IndianaClientException("invalid_response")
164
+ if (completion.opt("membership") !is Boolean || 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")
165
+ }
166
+ storeRestorePending.remove(purchase.purchaseToken); return result
167
+ }
168
+ /** Uses the fixed server instruction and actual Play quote; the server owns acknowledgement and grants. */
169
+ suspend fun purchaseSubscription(activity: Activity, operationId: UUID, confirm: suspend (GoogleQuote) -> Boolean): JSONObject {
170
+ if (!recoveringNative.compareAndSet(false,true)) throw IndianaClientException("command_pending")
171
+ try { return purchaseSubscriptionInternal(activity,operationId,false,confirm) } finally { recoveringNative.set(false) }
172
+ }
173
+ /** Uses a verified source purchase token and full-price Play replacement. */
174
+ suspend fun switchSubscription(activity: Activity, operationId: UUID, confirm: suspend (GoogleQuote) -> Boolean): JSONObject {
175
+ if (!recoveringNative.compareAndSet(false,true)) throw IndianaClientException("command_pending")
176
+ try { return purchaseSubscriptionInternal(activity,operationId,true,confirm) } finally { recoveringNative.set(false) }
177
+ }
178
+ private suspend fun purchaseSubscriptionInternal(activity: Activity, operationId: UUID, replacing:Boolean, confirm: suspend (GoogleQuote) -> Boolean): JSONObject {
179
+ checkOpen(); val actor = identity.get(); if (actor.token == null) throw IndianaClientException("unauthenticated")
180
+ val response = if(replacing) update(operationId) else attach(operationId); val operation = response.optJSONObject("operation") ?: throw IndianaClientException("invalid_response")
181
+ if (operation.optString("operationId") != operationId.toString()) throw IndianaClientException("invalid_response")
182
+ if (operation.optString("status") in listOf("complete","effective","cancelled","failed")) return response
183
+ val instruction = operation.optJSONObject("nativePurchase") ?: throw IndianaClientException("invalid_response")
184
+ if (instruction.optString("provider") != "google" || instruction.optString("receiptId") != operationId.toString()) throw IndianaClientException("invalid_response")
185
+ val order = safe { UUID.fromString(text(instruction,"orderId")) }
186
+ if (instruction.optString("obfuscatedProfileId") != order.toString()) throw IndianaClientException("invalid_response")
187
+ val product = text(instruction,"productId"); val basePlan = instruction.opt("basePlanId") as? String; val offer = instruction.opt("offerId") as? String
188
+ var terms = GoogleStoreTerms.parse(instruction); val result = CompletableDeferred<GoogleStoreResult>()
189
+ val store = GoogleStorefront(activity) { result.complete(it) }
190
+ try {
191
+ store.connect()
192
+ val scanned=store.scanPurchases(false)+store.scanPurchases(true)
193
+ if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
194
+ val replacement=instruction.optJSONObject("replacement")
195
+ if (replacing != (replacement != null) || (replacement != null && replacement.optString("policy") != "google_full_price")) throw IndianaClientException("invalid_response")
196
+ val purchased = scanned.singleOrNull { it.intentBinding == order.toString() && it.productId == product && it.accountBinding == text(instruction,"obfuscatedAccountId") }
197
+ if (purchased != null) return submitStorePurchase(purchased,order,actor.epoch)
198
+ if (replacement != null) {
199
+ terms=terms.copy(replacement=googleReplacement(instruction,scanned))
200
+ }
201
+ val quote = terms.validate(store.quote(product,basePlan,offer)).copy(replacementPolicy=if(replacing) "google_full_price" else null); val accepted = confirm(quote)
202
+ if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
203
+ if (!accepted) return if(replacing) update(operationId,"cancel") else attach(operationId,"cancel")
204
+ store.purchase(activity,product,basePlan,offer,text(instruction,"obfuscatedAccountId"),order.toString(),terms)
205
+ val outcome = result.await()
206
+ if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
207
+ return when (outcome) {
208
+ is GoogleStoreResult.Purchased -> {
209
+ if (outcome.evidence.productId != product || outcome.evidence.intentBinding != order.toString()) throw IndianaClientException("invalid_response")
210
+ submitStorePurchase(outcome.evidence,order,actor.epoch)
211
+ }
212
+ is GoogleStoreResult.Canceled -> if(replacing) update(operationId,"cancel") else attach(operationId,"cancel")
213
+ is GoogleStoreResult.Failed -> throw IndianaClientException(outcome.code)
214
+ }
215
+ } finally { store.close() }
216
+ }
217
+ suspend fun features(activity: Activity, attributes: JSONObject = JSONObject()): IndianaFeatureClient = kotlinx.coroutines.withContext(Dispatchers.Main.immediate) {
218
+ checkOpen(); val original = identity.get()
219
+ if (original.token == null) throw IndianaClientException("unauthenticated")
220
+ val client = IndianaFeatureClient(activity, configuration, attributes) { operation, input -> customerOperation(operation, input) }
221
+ synchronized(identityLock) {
222
+ if (identity.get().epoch != original.epoch || closed.get()) { client.close(); throw IndianaClientException("identity_changed") }
223
+ featuresClients.removeAll { it.get() == null }; featuresClients.add(WeakReference(client))
224
+ }
225
+ client
226
+ }
227
+ /** Reuse requestId and the entire input after an unknown acceptance result. */
228
+ suspend fun startFunnel(input: JSONObject, commandStorage: IndianaFunnelCommandStorage? = null): IndianaFunnelSession {
229
+ val original = identity.get(); val token = original.token ?: throw IndianaClientException("unauthenticated")
230
+ val command = JSONObject(input.toString())
231
+ if (command.has("customer") || command.has("customerData") || command.has("customerId") || (command.has("operation") && command.optString("operation") != "start")) throw IndianaClientException("invalid_request")
232
+ command.put("operation", "start"); IndianaSchema.validate("command", command)
233
+ val result = http.send("funnels", "POST", token, command)
234
+ synchronized(identityLock) {
235
+ if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed")
236
+ IndianaSchema.validate("response", result)
237
+ val run = result.getJSONObject("run")
238
+ if (scope(result.getJSONObject("scope")) != configuration.scope || result.has("customerId") || run.getJSONObject("source").getString("id") != command.getString("funnelId") || run.getString("locale") != command.getString("locale") || !IndianaSchema.equal(run.getJSONObject("parameters"), command.getJSONObject("parameters"))) throw IndianaClientException("invalid_response")
239
+ return funnel(result.getString("runToken"), commandStorage)
240
+ }
241
+ }
242
+ suspend fun track(name: String, properties: JSONObject = JSONObject(), eventId: UUID = UUID.randomUUID(), timestamp: java.util.Date = java.util.Date()) {
243
+ val event = JSONObject().put("eventUuid", eventId.toString()).put("timestamp", java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }.format(timestamp)).put("name", name).put("properties", JSONObject(properties.toString()))
244
+ val payload = JSONObject().put("events", JSONArray().put(event))
245
+ if (payload.toString().toByteArray().size > 60_000) throw IndianaClientException("invalid_request")
246
+ IndianaSchema.validate("customertrackEventsRequest", payload)
247
+ val full = synchronized(identityLock) {
248
+ checkOpen(); if (identity.get().token == null) throw IndianaClientException("unauthenticated")
249
+ if (analyticsEvents.size >= 1000) throw IndianaClientException("invalid_request")
250
+ analyticsEvents.add(event)
251
+ if (analyticsTimer == null) analyticsTimer = pushScope.launch { kotlinx.coroutines.delay(2_000); synchronized(identityLock) { analyticsTimer = null }; try { flush() } catch (_: Exception) { } }
252
+ analyticsEvents.size >= 100
253
+ }
254
+ if (full) flush()
255
+ }
256
+ suspend fun flush(): JSONObject = analyticsFlushLock.withLock {
257
+ val original = synchronized(identityLock) { analyticsTimer?.cancel(); analyticsTimer = null; identity.get() }
258
+ if (original.token == null) throw IndianaClientException("unauthenticated")
259
+ val total = JSONObject().put("inserted", 0).put("duplicates", 0).put("rejected", 0).put("skipped", 0)
260
+ var remaining = synchronized(identityLock) { analyticsEvents.size }
261
+ while (remaining > 0) {
262
+ val batch = synchronized(identityLock) {
263
+ if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed")
264
+ val selected = JSONArray()
265
+ for (event in analyticsEvents.take(minOf(remaining, 100))) {
266
+ val candidate = JSONArray(selected.toString()).put(event)
267
+ if (selected.length() > 0 && JSONObject().put("events", candidate).toString().toByteArray().size > 64_000) break
268
+ selected.put(event)
269
+ }; selected
270
+ }
271
+ val result = customerOperation(IndianaCustomerOperation.trackEvents, JSONObject().put("events", batch))
272
+ if (listOf("inserted", "duplicates", "rejected", "skipped").sumOf { result.getInt(it) } != batch.length()) throw IndianaClientException("invalid_response")
273
+ synchronized(identityLock) {
274
+ if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed")
275
+ repeat(batch.length()) { analyticsEvents.removeAt(0) }; remaining -= batch.length()
276
+ }
277
+ for (key in listOf("inserted", "duplicates", "rejected", "skipped")) total.put(key, total.getInt(key) + result.getInt(key))
278
+ }
279
+ total
280
+ }
281
+ suspend fun content(input: JSONObject, locale: String = "en", inputs: JSONObject = JSONObject()): IndianaContentSession {
282
+ val original = identity.get().epoch
283
+ val session = IndianaContentSession(configuration, input, locale, inputs) { request -> customerOperation(IndianaCustomerOperation.resolveContent, request) }
284
+ session.refresh()
285
+ synchronized(identityLock) {
286
+ if (identity.get().epoch != original || closed.get()) { session.close(); throw IndianaClientException("identity_changed") }
287
+ contents.removeAll { it.get() == null }; contents.add(WeakReference(session))
288
+ }
289
+ return session
290
+ }
291
+ /** Generated Customer capability boundary. Server-only operations cannot be selected. */
292
+ suspend fun customerOperation(operation: IndianaCustomerOperation, input: JSONObject): JSONObject {
293
+ checkOpen()
294
+ val original = identity.get()
295
+ val token = original.token ?: throw IndianaClientException("unauthenticated")
296
+ val payload = IndianaCustomerSemantics.defaults(operation.wireName, input)
297
+ if (payload.has("customerId") || payload.has("customerToken")) throw IndianaClientException("invalid_request")
298
+ IndianaCustomerSemantics.preflight(operation.wireName, payload)
299
+ try { IndianaSchema.validate("customer" + operation.wireName + "Request", payload) }
300
+ catch (_: Exception) { throw IndianaClientException("invalid_request") }
301
+ IndianaCustomerSemantics.request(operation.wireName, payload)
302
+ val response = try { http.send(operation.path, operation.method, token, if (operation.method == "GET") null else payload) }
303
+ catch (error: Exception) { if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed"); throw error }
304
+ if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed")
305
+ if (safe { scope(response.getJSONObject("scope")) } != configuration.scope) throw IndianaClientException("scope_mismatch")
306
+ IndianaSchema.validate("customer" + operation.wireName + "Response", response)
307
+ val normalized = try { IndianaSchema.customerResponse("customer" + operation.wireName + "Response", response) } catch (error: IndianaClientException) { throw error } catch (_: Exception) { throw IndianaClientException("invalid_response") }
308
+ IndianaCustomerSemantics.response(operation.wireName, normalized)
309
+ if (scope(normalized.getJSONObject("scope")) != configuration.scope) throw IndianaClientException("invalid_response")
310
+ return JSONObject(normalized.toString())
311
+ }
312
+ private fun checkOpen() { if (closed.get()) throw IndianaClientException("closed") }
313
+ private suspend fun authenticated(path: String, body: JSONObject, credential: String? = null): JSONObject {
314
+ if (credential == null) IndianaCustomerOperation.entries.firstOrNull { it.path == path }?.let { return customerOperation(it, body) }
315
+ checkOpen(); val actor = identity.get(); val token = credential ?: actor.token ?: throw IndianaClientException("unauthenticated")
316
+ if (actor.token == null) throw IndianaClientException("unauthenticated")
317
+ val response = try { http.send(path, "POST", token, body) } catch (error: Exception) { if (identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed"); throw error }
318
+ if (identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
319
+ if (safe { scope(response.getJSONObject("scope")) } != configuration.scope) throw IndianaClientException("scope_mismatch")
320
+ return response
321
+ }
322
+ suspend fun readHoldings(): JSONObject = authenticated("customer-holdings", JSONObject())
323
+ suspend fun checkEntitlement(internalFeatureId: UUID, quantity: String = "1", properties: JSONObject = JSONObject()): JSONObject {
324
+ val body = JSONObject().put("requirement", JSONObject().put("internal_feature_id", internalFeatureId.toString()).put("quantity", quantity).put("properties", JSONObject(properties.toString())))
325
+ IndianaSchema.validate("checkRequest", body)
326
+ val result = authenticated("entitlements/check", body)
327
+ IndianaSchema.validate("checkResponse", result)
328
+ return result
329
+ }
330
+ /** The authenticated merchant backend selects the Customer and adds its Server Key; retain idempotencyKey on retry. */
331
+ suspend fun trackUsage(command: JSONObject, relay: suspend (JSONObject) -> JSONObject): JSONObject {
332
+ checkOpen(); val actor = identity.get(); if (actor.token == null) throw IndianaClientException("unauthenticated")
333
+ val body = JSONObject(command.toString()); IndianaSchema.validate("usageCommand", body)
334
+ val result = try { relay(body) } catch (error: Exception) {
335
+ if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
336
+ throw error
337
+ }
338
+ if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
339
+ IndianaSchema.validate("usageResponse", result)
340
+ if (safe { scope(result.getJSONObject("scope")) } != configuration.scope) throw IndianaClientException("invalid_response")
341
+ return JSONObject(result.toString())
342
+ }
343
+ /** Keep requestId on retry. Channel execution follows server capabilities. */
344
+ suspend fun previewAttach(requestId: UUID, plans: List<IndianaSubscriptionPlan>, provider: IndianaSubscriptionProvider, currency: String, billingSubscriptionId: UUID? = null, offerId: UUID? = null, nativeOffer: IndianaNativeStoreOffer? = null): JSONObject {
345
+ if (plans.size !in 1..20 || plans.map { it.productVersionId }.distinct().size != plans.size || !currency.matches(Regex("[A-Z]{3}")) || plans.any { it.featureQuantities.size > 100 || it.featureQuantities.values.any { q -> q !in 0..10000 } || it.seatQuantities.size > 20 || it.seatQuantities.any { (key, quantity) -> !key.matches(Regex("[a-z][a-z0-9_]{0,63}")) || quantity !in 0..10000 } }) throw IndianaClientException("invalid_request")
346
+ val entries = JSONArray()
347
+ plans.forEach { entries.put(JSONObject().put("productVersionId", it.productVersionId.toString()).put("seatQuantities", JSONObject(it.seatQuantities)).put("featureQuantities", JSONObject(it.featureQuantities.mapKeys { entry -> entry.key.toString() }))) }
348
+ val body = JSONObject().put("requestId", requestId.toString()).put("plans", entries).put("provider", provider.value).put("currency", currency)
349
+ billingSubscriptionId?.let { body.put("billingSubscriptionId", it.toString()) }
350
+ if (nativeOffer != null && (!nativeOffer.matches(provider) || plans.size != 1) ||
351
+ offerId != null && (billingSubscriptionId != null || provider != IndianaSubscriptionProvider.STRIPE && nativeOffer == null)) throw IndianaClientException("invalid_request")
352
+ offerId?.let { body.put("offerId", it.toString()) }
353
+ nativeOffer?.let { body.put("nativeOffer", it.wire()) }
354
+ return authenticated("subscriptions/preview-attach", body)
355
+ }
356
+ /** Call after the customer accepts the frozen quote. */
357
+ suspend fun attach(operationId: UUID,intent:String="confirm"): JSONObject { if(intent !in listOf("confirm","cancel")) throw IndianaClientException("invalid_request");return authenticated("subscriptions/attach", JSONObject().put("operationId", operationId.toString()).put("intent",intent)) }
358
+ suspend fun previewUpdate(requestId: UUID, membershipId: UUID, productVersionId: UUID, timing: String): JSONObject {
359
+ if (timing !in listOf("immediate", "renewal", "store")) throw IndianaClientException("invalid_request")
360
+ return authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", timing).put("change", JSONObject().put("type", "plan").put("productVersionId", productVersionId.toString())))
361
+ }
362
+ suspend fun previewSeatUpdate(requestId: UUID, membershipId: UUID, licenseKey: String, quantity: Int, timing: String): JSONObject {
363
+ if (timing !in listOf("immediate", "renewal") || quantity !in 0..10000 || !licenseKey.matches(Regex("[a-z][a-z0-9_]{0,63}"))) throw IndianaClientException("invalid_request")
364
+ return authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", timing).put("change", JSONObject().put("type", "seats").put("licenseKey", licenseKey).put("quantity", quantity)))
365
+ }
366
+ /** Executes according to the held plan's billing channel; Play plans require store support. */
367
+ suspend fun previewQuantityUpdate(requestId: UUID, membershipId: UUID, internal_feature_id: UUID, quantity: Int, timing: String): JSONObject {
368
+ if (timing !in listOf("immediate", "renewal") || quantity !in 0..10000) throw IndianaClientException("invalid_request")
369
+ return authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", timing).put("change", JSONObject().put("type", "quantity").put("internal_feature_id", internal_feature_id.toString()).put("quantity", quantity)))
370
+ }
371
+ suspend fun previewCancellation(requestId: UUID, membershipId: UUID, timing: String): JSONObject {
372
+ if (timing !in listOf("immediate", "renewal")) throw IndianaClientException("invalid_request")
373
+ return authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", timing).put("change", JSONObject().put("type", "cancel")))
374
+ }
375
+ suspend fun previewUncancel(requestId: UUID, membershipId: UUID): JSONObject = authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", "immediate").put("change", JSONObject().put("type", "uncancel")))
376
+ suspend fun update(operationId: UUID,intent:String="confirm"): JSONObject { if(intent !in listOf("confirm","cancel")) throw IndianaClientException("invalid_request");return authenticated("subscriptions/update", JSONObject().put("operationId", operationId.toString()).put("intent",intent)) }
377
+ suspend fun readSubscriptionUpdate(operationId: UUID): JSONObject = authenticated("subscriptions/updates/read", JSONObject().put("operationId", operationId.toString()))
378
+ suspend fun readSubscriptionOperation(operationId: UUID): JSONObject = authenticated("subscriptions/operations/read", JSONObject().put("operationId", operationId.toString()))
379
+ suspend fun readLedger(query: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readLedger, JSONObject().put("query", JSONObject(query.toString())))
380
+ suspend fun quoteBalance(internal_feature_id: UUID, quantity: String, properties: JSONObject = JSONObject()): JSONObject = authenticated("balances/quote", JSONObject().put("command", JSONObject().put("operation", "quote").put("internal_feature_id", internal_feature_id.toString()).put("quantity", quantity).put("properties", JSONObject(properties.toString()))))
381
+ /** Enables the Push capability of this client. The App owns OS permission prompts. */
382
+ fun enablePush() = synchronized(identityLock) {
383
+ checkOpen(); if (pushIdentity != null) return@synchronized
384
+ val installation = IndianaPushIdentity(pushStorage?.invoke() ?: throw IndianaClientException("invalid_configuration"))
385
+ if (identity.get().token != null) pushBindingSequence = installation.reserve()
386
+ pushIdentity = installation; schedulePush()
387
+ }
388
+ private fun schedulePush() {
389
+ if (pushIdentity == null || pushSynchronizationError == "protected_storage_unavailable" || closed.get()) return
390
+ val original = identity.get().epoch
391
+ pushScope.launch {
392
+ try { syncPush() } catch (error: Exception) {
393
+ synchronized(identityLock) { if (identity.get().epoch == original) pushSynchronizationError = (error as? IndianaClientException)?.code ?: "temporary_unavailable" }
394
+ }
395
+ }
396
+ }
397
+ suspend fun syncPush(): JSONObject? = pushSyncLock.withLock {
398
+ val (installation, actor, binding) = synchronized(identityLock) {
399
+ checkOpen(); if (pushSynchronizationError == "protected_storage_unavailable") throw IndianaClientException("protected_storage_unavailable")
400
+ Triple(pushIdentity ?: throw IndianaClientException("invalid_configuration"), identity.get(), pushBindingSequence)
401
+ }
402
+ val state = installation.state()
403
+ var missingDetach = false
404
+ state.detachSeq?.let { sequence ->
405
+ val command = installation.command("detach", configuration.scope, sequence)
406
+ try {
407
+ val acknowledged = validatePush(http.send("push/installations/detach", "POST", "", command), state.installationId)
408
+ if (acknowledged.getBoolean("bound") || acknowledged.getLong("identitySeq") != sequence) throw IndianaClientException("invalid_response")
409
+ }
410
+ catch (error: IndianaClientException) { if (error.code == "request_failed_404") missingDetach = true else if (error.code != "request_failed_412") throw error }
411
+ if (!missingDetach) installation.clearDetach(sequence)
412
+ }
413
+ checkPushEpoch(actor.epoch)
414
+ if (actor.token == null && missingDetach) throw IndianaClientException("request_failed_404")
415
+ val response = actor.token?.let { token ->
416
+ val proofCredential = http.access(); checkPushEpoch(actor.epoch)
417
+ val command = installation.command("bind", configuration.scope, binding, proofCredential)
418
+ checkPushEpoch(actor.epoch)
419
+ validatePush(authenticated("push/installations/bind", command, proofCredential), state.installationId).also {
420
+ if (!it.getBoolean("bound") || it.getLong("identitySeq") != binding) throw IndianaClientException("invalid_response")
421
+ }
422
+ }
423
+ if (missingDetach && state.detachSeq != null && binding > state.detachSeq) installation.clearDetach(state.detachSeq)
424
+ synchronized(identityLock) { checkPushEpoch(actor.epoch); pushSynchronizationError = null }
425
+ response
426
+ }
427
+ suspend fun readPushInstallation(): JSONObject {
428
+ val (installation, actor) = synchronized(identityLock) { checkOpen(); (pushIdentity ?: throw IndianaClientException("invalid_configuration")) to identity.get() }
429
+ val proofCredential = http.access(); checkPushEpoch(actor.epoch)
430
+ val command = installation.command("read", configuration.scope, credential = proofCredential)
431
+ checkPushEpoch(actor.epoch)
432
+ return validatePush(authenticated("push/installations/read", command, proofCredential), installation.state().installationId).also { checkPushEpoch(actor.epoch) }
433
+ }
434
+ fun capturePushObservation(): IndianaPushObservationTicket = synchronized(identityLock) {
435
+ checkOpen(); if (pushIdentity == null) throw IndianaClientException("invalid_configuration")
436
+ pushObservationID = UUID.randomUUID(); IndianaPushObservationTicket(pushClientID, identity.get().epoch, pushObservationID)
437
+ }
438
+ suspend fun recoverPushCounters(): JSONObject? {
439
+ val (installation, actor, expected) = synchronized(identityLock) {
440
+ checkOpen(); pushObservationID = UUID.randomUUID()
441
+ Triple(pushIdentity ?: throw IndianaClientException("invalid_configuration"), identity.get(), pushBindingSequence)
442
+ }
443
+ val remote = readPushInstallation()
444
+ synchronized(identityLock) { checkPushEpoch(actor.epoch); pushBindingSequence = installation.recover(remote, expected) }
445
+ return syncPush()
446
+ }
447
+ suspend fun observePush(observation: IndianaPushObservation, ticket: IndianaPushObservationTicket): JSONObject? {
448
+ fun current() = ticket.client == pushClientID && ticket.identity == identity.get().epoch && ticket.observation == pushObservationID
449
+ if (!synchronized(identityLock) { checkOpen(); current() }) return null
450
+ syncPush()
451
+ val proofCredential = http.access(); checkPushEpoch(ticket.identity)
452
+ val command = synchronized(identityLock) {
453
+ if (!current()) return null
454
+ val installation = pushIdentity ?: throw IndianaClientException("invalid_configuration")
455
+ installation.command("address", configuration.scope, installation.reserveAddress(), proofCredential, observation)
456
+ }
457
+ checkPushEpoch(ticket.identity)
458
+ val response = authenticated("push/installations/address", command, proofCredential)
459
+ checkPushEpoch(ticket.identity)
460
+ return validatePush(response, UUID.fromString(command.getString("installationId")))
461
+ }
462
+ /** Requests a security notification; the challenge arrives through the native FCM callback. */
463
+ suspend fun requestPushAddressRecovery(observation: IndianaPushObservation, ticket: IndianaPushObservationTicket): JSONObject {
464
+ recoveryPushIdentity(observation, ticket)
465
+ syncPush()
466
+ val proofCredential = http.access(); checkPushEpoch(ticket.identity)
467
+ val command = synchronized(identityLock) {
468
+ val (installation, token) = recoveryPushIdentity(observation, ticket)
469
+ installation.command("recovery_request", configuration.scope, installation.reserveAddress(), proofCredential, observation)
470
+ }
471
+ val response = authenticated("push/installations/recovery/request", command, proofCredential)
472
+ return synchronized(identityLock) {
473
+ recoveryPushIdentity(observation, ticket)
474
+ safe {
475
+ if (scope(response.getJSONObject("scope")) != configuration.scope) throw IndianaClientException("invalid_response")
476
+ val recovery = response.getJSONObject("recovery")
477
+ keys(recovery, setOf("requested", "expiresAt"))
478
+ if (recovery.opt("requested") != true) throw IndianaClientException("invalid_response")
479
+ utcMillis(text(recovery, "expiresAt"))
480
+ JSONObject(recovery.toString())
481
+ }
482
+ }
483
+ }
484
+ /** Reacquire the current FCM token under a fresh ticket before confirming a native challenge. */
485
+ suspend fun confirmPushAddressRecovery(challenge: String, observation: IndianaPushObservation, ticket: IndianaPushObservationTicket): JSONObject {
486
+ val proofCredential = http.access(); checkPushEpoch(ticket.identity)
487
+ val command = synchronized(identityLock) {
488
+ val (installation, token) = recoveryPushIdentity(observation, ticket)
489
+ val state = installation.state()
490
+ if (state.addressSeq == 0L) throw IndianaClientException("invalid_configuration")
491
+ installation.command("recovery_confirm", configuration.scope, state.addressSeq, proofCredential, observation, challenge)
492
+ }
493
+ val response = authenticated("push/installations/recovery/confirm", command, proofCredential)
494
+ return synchronized(identityLock) {
495
+ val (installation, _) = recoveryPushIdentity(observation, ticket)
496
+ if (installation.state().addressSeq != command.getLong("sequence")) throw IndianaClientException("identity_changed")
497
+ val state = validatePush(response, UUID.fromString(command.getString("installationId")))
498
+ if (!state.getBoolean("bound") || state.getLong("identitySeq") != pushBindingSequence || state.getLong("addressSeq") != command.getLong("sequence") ||
499
+ state.optString("connectionId") != observation.connectionId.toString() || state.optString("addressStatus") != "active") throw IndianaClientException("invalid_response")
500
+ state
501
+ }
502
+ }
503
+ private fun recoveryPushIdentity(observation: IndianaPushObservation, ticket: IndianaPushObservationTicket): Pair<IndianaPushIdentity, String> = synchronized(identityLock) {
504
+ checkOpen()
505
+ if (ticket.client != pushClientID || ticket.identity != identity.get().epoch || ticket.observation != pushObservationID) throw IndianaClientException("identity_changed")
506
+ val installation = pushIdentity ?: throw IndianaClientException("invalid_configuration")
507
+ val token = identity.get().token ?: throw IndianaClientException("unauthenticated")
508
+ if (observation.connectionId == null || observation.kind != IndianaPushAddressKind.FCM_TOKEN || observation.token == null ||
509
+ observation.permission !in setOf(IndianaPushPermission.GRANTED, IndianaPushPermission.PROVISIONAL)) throw IndianaClientException("invalid_configuration")
510
+ installation to token
511
+ }
512
+ suspend fun readPushPreference(): JSONObject = authenticated("messaging/preferences/read", JSONObject().put("channel", "push"))
513
+ suspend fun setPushPreference(allowed: Boolean, revision: Long): JSONObject {
514
+ if (revision !in 0 until PUSH_MAX_SEQUENCE) throw IndianaClientException("invalid_request")
515
+ return authenticated("messaging/preferences/set", JSONObject().put("requestId", UUID.randomUUID().toString()).put("channel", "push").put("state", if (allowed) "allowed" else "denied").put("expectedRevision", revision))
516
+ }
517
+ private fun checkPushEpoch(original: UUID) { if (closed.get() || identity.get().epoch != original) throw IndianaClientException("identity_changed") }
518
+ private fun validatePush(response: JSONObject, installation: UUID): JSONObject = safe {
519
+ if (scope(response.getJSONObject("scope")) != configuration.scope) throw IndianaClientException("invalid_response")
520
+ val state = response.getJSONObject("installation")
521
+ if (UUID.fromString(state.getString("installationId")) != installation || state.opt("bound") !is Boolean || state.getLong("identitySeq") !in 1..PUSH_MAX_SEQUENCE || state.getLong("bindingGeneration") !in 1..PUSH_MAX_SEQUENCE || state.getLong("addressSeq") !in 0..PUSH_MAX_SEQUENCE || state.getLong("addressRevision") !in 0..PUSH_MAX_SEQUENCE || state.getString("permission") !in IndianaPushPermission.entries.map { it.value } || (!state.isNull("addressStatus") && state.getString("addressStatus") !in setOf("active", "invalid"))) throw IndianaClientException("invalid_response")
522
+ if (!state.isNull("connectionId")) UUID.fromString(state.getString("connectionId"))
523
+ JSONObject(state.toString())
524
+ }
525
+ override fun close() { if (closed.compareAndSet(false, true)) { reset(); pushScope.cancel() } }
526
+ }
@@ -0,0 +1,81 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import android.content.Context
4
+ import android.security.keystore.KeyGenParameterSpec
5
+ import android.security.keystore.KeyProperties
6
+ import android.util.AtomicFile
7
+ import android.util.Base64
8
+ import org.json.JSONObject
9
+ import java.io.File
10
+ import java.security.KeyStore
11
+ import java.security.MessageDigest
12
+ import java.util.UUID
13
+ import javax.crypto.Cipher
14
+ import javax.crypto.KeyGenerator
15
+ import javax.crypto.SecretKey
16
+ import javax.crypto.spec.GCMParameterSpec
17
+
18
+ internal data class IndianaNativePendingOrder(val runId: UUID, val orderId: UUID, val receiptId: UUID, val productId: String, val accountId: String, val profileId: String, val basePlanId: String?, val offerId: String?) {
19
+ fun json(): JSONObject = JSONObject().put("runId", runId.toString()).put("orderId", orderId.toString()).put("receiptId", receiptId.toString()).put("productId", productId).put("obfuscatedAccountId", accountId).put("obfuscatedProfileId", profileId).put("basePlanId", basePlanId ?: JSONObject.NULL).put("offerId", offerId ?: JSONObject.NULL)
20
+ companion object {
21
+ fun parse(value: JSONObject, runId: UUID = UUID.fromString(value.getString("runId"))): IndianaNativePendingOrder {
22
+ val result = IndianaNativePendingOrder(runId, UUID.fromString(value.getString("orderId")), UUID.fromString(value.getString("receiptId")), value.getString("productId"), value.getString("obfuscatedAccountId"), value.getString("obfuscatedProfileId"), value.optString("basePlanId").takeIf { value.has("basePlanId") && !value.isNull("basePlanId") }, value.optString("offerId").takeIf { value.has("offerId") && !value.isNull("offerId") })
23
+ if (result.productId.length !in 1..255 || result.accountId.length !in 1..64 || result.profileId.length !in 1..64) throw IndianaClientException("invalid_response")
24
+ return result
25
+ }
26
+ }
27
+ }
28
+ /** Current order metadata only. No token/JWS/customer credential or legacy storage is read. */
29
+ internal interface IndianaNativePendingStorage {
30
+ fun read(order: UUID): IndianaNativePendingOrder?
31
+ fun orders(): List<IndianaNativePendingOrder>
32
+ fun remove(order: UUID)
33
+ }
34
+ internal class IndianaNativePendingKeystore(context: Context, scope: IndianaScope): IndianaNativePendingStorage {
35
+ private val namespace = "${context.packageName}:${scope.projectId}:${scope.environment.value}"
36
+ private val hash = MessageDigest.getInstance("SHA-256").digest(namespace.toByteArray()).joinToString("") { "%02x".format(it) }
37
+ private val directory = File(context.noBackupFilesDir, "grow-funnel-orders-$hash")
38
+ private val alias = "grow.funnel.orders.v1.$hash"
39
+ companion object { private val lock = Any() }
40
+ private fun file(order: UUID) = AtomicFile(File(directory, "$order.json"))
41
+ private fun aad(order: UUID) = "grow_funnel_order_v1\u0000$namespace\u0000$order".toByteArray()
42
+ private fun key(create: Boolean): SecretKey {
43
+ val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
44
+ (store.getKey(alias, null) as? SecretKey)?.let { return it }
45
+ if (!create || directory.listFiles()?.any { it.isFile } == true) throw IndianaClientException("protected_storage_unavailable")
46
+ val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).setKeySize(256).setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setRandomizedEncryptionRequired(true).build()
47
+ return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { init(spec) }.generateKey()
48
+ }
49
+ override fun read(order: UUID): IndianaNativePendingOrder? = synchronized(lock) {
50
+ try {
51
+ if (!file(order).baseFile.exists()) return@synchronized null
52
+ val bytes = file(order).openRead().use { input -> val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(1024); while (true) { val count = input.read(buffer); if (count < 0) break; if (output.size() + count > 8192) throw IndianaClientException("protected_storage_unavailable"); output.write(buffer, 0, count) }; output.toByteArray() }
53
+ val value = JSONObject(bytes.toString(Charsets.UTF_8)); if (value.keys().asSequence().toSet() != setOf("format", "nonce", "ciphertext") || value.getString("format") != "grow_funnel_order_v1") throw IndianaClientException("protected_storage_unavailable")
54
+ val nonce = Base64.decode(value.getString("nonce"), Base64.NO_WRAP); if (nonce.size != 12) throw IndianaClientException("protected_storage_unavailable")
55
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, key(false), GCMParameterSpec(128, nonce)); cipher.updateAAD(aad(order))
56
+ val plaintext = cipher.doFinal(Base64.decode(value.getString("ciphertext"), Base64.NO_WRAP))
57
+ try { IndianaNativePendingOrder.parse(JSONObject(plaintext.toString(Charsets.UTF_8))).also { if (it.orderId != order) throw IndianaClientException("protected_storage_unavailable") } } finally { plaintext.fill(0) }
58
+ } catch (_: Exception) { throw IndianaClientException("protected_storage_unavailable") }
59
+ }
60
+ override fun orders(): List<IndianaNativePendingOrder> = synchronized(lock) {
61
+ val files = directory.listFiles()?.filter { it.name.endsWith(".json") } ?: emptyList()
62
+ if (files.size > 100) throw IndianaClientException("protected_storage_unavailable")
63
+ files.mapNotNull { read(UUID.fromString(it.name.removeSuffix(".json"))) }
64
+ }
65
+ fun orders(runId: UUID): List<IndianaNativePendingOrder> = orders().filter { it.runId == runId }
66
+ fun contains(record: IndianaNativePendingOrder): Boolean { val previous = read(record.orderId) ?: return false; if (previous != record) throw IndianaClientException("protected_storage_unavailable"); return true }
67
+ fun put(record: IndianaNativePendingOrder) = synchronized(lock) {
68
+ if (contains(record)) return@synchronized
69
+ if ((directory.listFiles()?.count { it.name.endsWith(".json") } ?: 0) >= 100) throw IndianaClientException("protected_storage_unavailable")
70
+ val currentKey = key(true)
71
+ if (!directory.exists() && !directory.mkdirs()) throw IndianaClientException("protected_storage_unavailable")
72
+ val plaintext = record.json().toString().toByteArray()
73
+ try {
74
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, currentKey); cipher.updateAAD(aad(record.orderId))
75
+ val bytes = JSONObject().put("format", "grow_funnel_order_v1").put("nonce", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)).put("ciphertext", Base64.encodeToString(cipher.doFinal(plaintext), Base64.NO_WRAP)).toString().toByteArray()
76
+ val atomic = file(record.orderId); val stream = atomic.startWrite()
77
+ try { stream.write(bytes); atomic.finishWrite(stream) } catch (error: Exception) { atomic.failWrite(stream); throw error }
78
+ } finally { plaintext.fill(0) }
79
+ }
80
+ override fun remove(order: UUID) = synchronized(lock) { file(order).delete() }
81
+ }