@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,208 @@
1
+ package ai.tinkerland.indiana
2
+ import kotlinx.coroutines.sync.Mutex
3
+ import kotlinx.coroutines.sync.withLock
4
+ import org.json.JSONObject
5
+ import java.net.URI
6
+ import java.util.UUID
7
+ import java.util.concurrent.atomic.AtomicReference
8
+ /** A Funnel session owns navigation. Native financial facts and receipt recovery remain independent. */
9
+ class IndianaFunnelSession internal constructor(private val configuration: IndianaConfiguration, private val http: IndianaHTTP, private val runToken: String, commandStorage: IndianaFunnelCommandStorage? = null, private val publicPaywallContent: (suspend (JSONObject) -> JSONObject)? = null) {
10
+ @JvmOverloads constructor(configuration: IndianaConfiguration, runToken: String, commandStorage: IndianaFunnelCommandStorage? = null): this(configuration, IndianaURLTransport(configuration), runToken, commandStorage)
11
+ constructor(context: android.content.Context, configuration: IndianaConfiguration, runToken: String): this(configuration, IndianaURLTransport(configuration), runToken, IndianaFunnelCommandKeystore(context.applicationContext,configuration,runToken))
12
+ private val journal = commandStorage?.let { IndianaFunnelCommandJournal(it,configuration.scope) }
13
+ private val restorationMutex = Mutex()
14
+ private var restored = false
15
+ private val purchaseRecovery = AtomicReference<JSONObject?>(null)
16
+ val pendingPurchase: JSONObject? get() = purchaseRecovery.get()?.let { JSONObject(it.toString()) }
17
+ private data class Identity(val token: String?, val epoch: UUID)
18
+ private val identity = AtomicReference(Identity(null, UUID.randomUUID()))
19
+ private val view = AtomicReference<JSONObject?>(null)
20
+ private val response = AtomicReference<JSONObject?>(null)
21
+ private val publicPresentation = AtomicReference<JSONObject?>(null)
22
+ private val pending = AtomicReference<String?>(null)
23
+ private val closingCommand = AtomicReference<String?>(null)
24
+ private val uncertain = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
25
+ private val settlements = java.util.concurrent.ConcurrentHashMap<String,String>()
26
+ @Volatile private var closeRequested = false
27
+ private val mutex = Mutex()
28
+ private val responseMutex = Mutex()
29
+ private val responseState = Any()
30
+ @Volatile private var invalidated = false
31
+ private var onInvalidated: (() -> Unit)? = null
32
+ init { if (!runToken.matches(Regex("gg_fr_[A-Za-z0-9_-]{43}"))) throw IndianaClientException("invalid_run_token") }
33
+ val current: JSONObject? get() = view.get()?.let { JSONObject(it.toString()) }
34
+ internal fun hostConfiguration() = configuration
35
+ internal fun hostPendingCommand(): JSONObject? = (closingCommand.get() ?: pending.get())?.let { JSONObject(it) }
36
+ internal fun onInvalidation(callback: () -> Unit) { onInvalidated = callback;if (invalidated) callback() }
37
+ fun identify(customerToken: String) = synchronized(responseState) {
38
+ if (invalidated || !customerToken.matches(Regex("gg_ct_[A-Za-z0-9_-]{32,128}"))) throw IndianaClientException("unauthenticated")
39
+ identity.set(Identity(customerToken, UUID.randomUUID()));response.set(null);publicPresentation.set(null)
40
+ }
41
+ fun reset() {
42
+ val callback = synchronized(responseState) {
43
+ invalidated = true;identity.set(Identity(null, UUID.randomUUID()));pending.set(null);closingCommand.set(null);purchaseRecovery.set(null);uncertain.clear();settlements.clear();view.set(null);response.set(null);publicPresentation.set(null)
44
+ val callback = onInvalidated;onInvalidated = null;callback
45
+ }
46
+ callback?.invoke()
47
+ }
48
+ private suspend fun restoreCommands() {
49
+ if (journal == null || restored) return
50
+ restorationMutex.withLock {
51
+ if (restored) return@withLock
52
+ val actor = identity.get();val record = journal.load()
53
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
54
+ pending.set(record?.optJSONObject("command")?.toString());closingCommand.set(record?.optJSONObject("closeCommand")?.toString());purchaseRecovery.set(record?.optJSONObject("purchase"))
55
+ closeRequested = record?.optBoolean("closed") == true || record?.optJSONObject("command")?.optString("operation") == "close"
56
+ for (command in listOf(pending.get(),closingCommand.get())) command?.let { uncertain.add(JSONObject(it).getString("requestId")) }
57
+ restored = true
58
+ }
59
+ }
60
+ private suspend fun call(command: JSONObject): JSONObject {
61
+ restoreCommands()
62
+ if (invalidated) throw IndianaClientException("identity_changed")
63
+ val actor = identity.get();val body = JSONObject(command.toString());actor.token?.let { body.put("customerToken", it) }
64
+ if (body.toString().toByteArray(Charsets.UTF_8).size > 262144) throw IndianaClientException("invalid_command")
65
+ IndianaSchema.validate("command", body)
66
+ val next = http.send("funnels", "POST", runToken, body)
67
+ return responseMutex.withLock {
68
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
69
+ IndianaSchema.validate("response", next)
70
+ val scope = next.getJSONObject("scope")
71
+ if (scope.getString("projectId") != configuration.scope.projectId.toString() || scope.getString("environment") != configuration.scope.environment.value) throw IndianaClientException("scope_mismatch")
72
+ val run = next.getJSONObject("run");val previous = view.get();val source = run.getJSONObject("source")
73
+ if (source.getString("type") != "funnel" || previous != null && (previous.getString("id") != run.getString("id") || !IndianaSchema.equal(previous.getJSONObject("source"), source) || previous.getString("locale") != run.getString("locale") || !IndianaSchema.equal(previous.getJSONObject("parameters"), run.getJSONObject("parameters")))) throw IndianaClientException("invalid_response")
74
+ journal?.bind(run)
75
+ run.optJSONObject("instruction")?.takeIf { it.optString("type") == "open_url" }?.let { https(it.getString("url")) }
76
+ val settlementId = command.optString("settlementRequestId").ifEmpty { command.optString("requestId") }
77
+ if (settlementId.isNotEmpty() && next.optJSONObject("settlement")?.optString("requestId") != settlementId) throw IndianaClientException("invalid_response")
78
+ synchronized(responseState) {
79
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
80
+ next.optJSONObject("settlement")?.let { settlements[it.getString("requestId")] = it.getString("state") }
81
+ }
82
+ val oldPaywall = previous?.optJSONObject("paywall"); val newPaywall = run.optJSONObject("paywall")
83
+ if (oldPaywall != null && newPaywall != null && oldPaywall.optString("receiptId") == newPaywall.optString("receiptId") && oldPaywall.getJSONObject("resolution").getJSONObject("content").getJSONObject("artifact").getString("sha256") != newPaywall.getJSONObject("resolution").getJSONObject("content").getJSONObject("artifact").getString("sha256")) throw IndianaClientException("invalid_response")
84
+ if (oldPaywall != null && newPaywall != null && oldPaywall.optString("receiptId") == newPaywall.optString("receiptId") && newPaywall.optLong("selectionGeneration") < oldPaywall.optLong("selectionGeneration")) return current!!
85
+ if (journal != null && command.optString("operation") != "review" && (previous == null || run.getInt("revision") >= previous.getInt("revision"))) {
86
+ val paywall = run.optJSONObject("paywall"); val purchase = paywall ?: next.optJSONObject("purchase"); val operation = purchase?.optJSONObject("operation")
87
+ val settlement = next.optJSONObject("settlement")?.takeIf { (paywall == null || paywall.optString("purchaseRequestId") == it.optString("requestId")) && (purchaseRecovery.get() == null || purchaseRecovery.get()?.optString("operationId") == it.optString("operationId")) }
88
+ val operationId = operation?.optString("operationId") ?: purchase?.optJSONObject("quote")?.optString("operationId") ?: settlement?.optString("operationId")?.takeIf { !settlement.isNull("operationId") }
89
+ val receiptId = purchase?.optString("receiptId") ?: settlement?.optString("receiptId")?.takeIf { !settlement.isNull("receiptId") }
90
+ if (!operationId.isNullOrEmpty() && !receiptId.isNullOrEmpty()) {
91
+ val generation = paywall?.optLong("selectionGeneration") ?: purchaseRecovery.get()?.optLong("generation") ?: 0
92
+ val reference = JSONObject().put("receiptId",receiptId).put("operationId",operationId).put("generation",generation)
93
+ val terminal = operation?.optString("status") == "complete" || purchase?.optBoolean("canReselect") == true
94
+ if (generation >= (purchaseRecovery.get()?.optLong("generation") ?: 0L)) {
95
+ journal.purchase(reference,terminal,run)
96
+ synchronized(responseState) {
97
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
98
+ if (!terminal) purchaseRecovery.set(reference) else if (purchaseRecovery.get()?.optString("operationId") == operationId) purchaseRecovery.set(null)
99
+ }
100
+ }
101
+ }
102
+ }
103
+ synchronized(responseState) {
104
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
105
+ if (previous == null || run.getInt("revision") >= previous.getInt("revision")) {
106
+ response.set(JSONObject(next.toString()));view.set(JSONObject(run.toString()))
107
+ }
108
+ current!!
109
+ }
110
+ }
111
+ }
112
+ suspend fun get(data: JSONObject? = null, purchase: JSONObject? = null): JSONObject { restoreCommands(); val command = JSONObject().put("operation", "get");data?.let { command.put("data", it) };val reference = purchase ?: purchaseRecovery.get()?.let { JSONObject().put("receiptId",it.getString("receiptId")).put("operationId",it.getString("operationId")) };reference?.let { command.put("purchase",it) };return call(command) }
113
+ private suspend fun mutate(command: JSONObject): JSONObject {
114
+ restoreCommands()
115
+ val original = command.toString();val closing = command.optString("operation") == "close";val id = command.optString("requestId")
116
+ if (!closing && closeRequested) throw IndianaClientException("command_pending")
117
+ suspend fun dispatch(): JSONObject {
118
+ val slot = if (closing) closingCommand else pending
119
+ val prior = slot.get();if (prior != null && !IndianaSchema.equal(JSONObject(prior), command)) throw IndianaClientException("command_pending")
120
+ slot.set(original);val wasUncertain = !uncertain.add(id)
121
+ try {
122
+ journal?.let { it.retain(command,current ?: throw IndianaClientException("invalid_command")) }
123
+ return call(command).also {
124
+ val state = settlements[id]
125
+ if (it.optJSONObject("pendingRequest")?.optString("requestId") != id && state == "settled" && (!closeRequested || closing || it.optString("status") !in setOf("running","waiting"))) { journal?.settle(id);slot.compareAndSet(original,null);uncertain.remove(id) }
126
+ }
127
+ } catch (error: Exception) {
128
+ val code = (error as? IndianaClientException)?.code?.removePrefix("request_failed_")?.toIntOrNull()
129
+ if (!wasUncertain && !closeRequested && code != null && code < 500 && code !in setOf(401,425)) { journal?.settle(id);slot.compareAndSet(original,null);uncertain.remove(id) };throw error
130
+ }
131
+ }
132
+ if (closing) { closeRequested = true;return dispatch() }
133
+ return mutex.withLock { dispatch() }
134
+ }
135
+ suspend fun settle(requestId: UUID): JSONObject {
136
+ val id = requestId.toString();settlements.remove(id)
137
+ val result = call(JSONObject().put("operation","get").put("settlementRequestId",id))
138
+ if (settlements[id] == "settled") {
139
+ journal?.settle(id)
140
+ for (slot in listOf(pending,closingCommand)) { val old = slot.get();if (old != null && JSONObject(old).optString("requestId") == id) slot.compareAndSet(old,null) };uncertain.remove(id)
141
+ }
142
+ return result
143
+ }
144
+ private fun base(operation: String, requestId: UUID): JSONObject { val run = current ?: throw IndianaClientException("invalid_command");return JSONObject().put("operation", operation).put("requestId", requestId.toString()).put("nodeId", run.getString("nodeId")).put("visit", run.getInt("visit")).put("revision", run.getInt("revision")) }
145
+ suspend fun submit(input: JSONObject, requestId: UUID = UUID.randomUUID()) = mutate(base("submit", requestId).put("input", input))
146
+ suspend fun submit(output: String, answers: JSONObject, selection: JSONObject = JSONObject(), requestId: UUID = UUID.randomUUID()) = submit(JSONObject().put("type", "screen").put("output", output).put("answers", answers).put("selection", selection), requestId)
147
+ suspend fun back(requestId: UUID = UUID.randomUUID()) = mutate(base("back", requestId))
148
+ suspend fun shown(requestId: UUID = UUID.randomUUID()) = mutate(base("shown", requestId))
149
+ suspend fun close(requestId: UUID = UUID.randomUUID()) = mutate(base("close", requestId))
150
+ suspend fun retry(): JSONObject {
151
+ restoreCommands(); if (current == null) get()
152
+ closingCommand.get()?.let { return mutate(JSONObject(it)) }
153
+ val original = pending.get()?.let { JSONObject(it) } ?: throw IndianaClientException("invalid_command")
154
+ return if (closeRequested) settle(UUID.fromString(original.getString("requestId"))) else mutate(original)
155
+ }
156
+ suspend fun resume(requestId: UUID): JSONObject { restoreCommands();if (current == null) get();if (closeRequested) return settle(requestId);pending.get()?.let { val command = JSONObject(it);if (command.optString("requestId") != requestId.toString()) throw IndianaClientException("command_pending");return mutate(command) };return mutate(JSONObject().put("operation", "resume").put("requestId", requestId.toString())) }
157
+ internal suspend fun hostCommand(command: JSONObject): JSONObject {
158
+ if (command.has("customerToken") || command.optString("operation") == "start") throw IndianaClientException("invalid_command")
159
+ if (command.optString("operation") == "get") { if (command.has("settlementRequestId")) settle(UUID.fromString(command.getString("settlementRequestId"))) else get(command.optJSONObject("data"), command.optJSONObject("purchase")) } else if (command.optString("operation") == "review") call(command) else mutate(command)
160
+ return hostResponse()
161
+ }
162
+ internal fun hostResponse(): JSONObject = response.get()?.let { JSONObject(it.toString()) } ?: throw IndianaClientException("identity_changed")
163
+ /** Display-only read; settlement and durable purchase recovery never call this transport. */
164
+ internal suspend fun hostPaywallContent(payload: JSONObject): JSONObject {
165
+ if (invalidated || payload.keys().asSequence().toSet() != setOf("id", "sha256")) throw IndianaClientException("invalid_command")
166
+ val paywall = current?.optJSONObject("paywall") ?: throw IndianaClientException("invalid_command")
167
+ val delivery = paywall.getJSONObject("resolution")
168
+ if (payload.optString("id") != delivery.getString("id") || payload.optString("sha256") != delivery.getJSONObject("content").getJSONObject("artifact").getString("sha256")) throw IndianaClientException("invalid_command")
169
+ val actor = identity.get()
170
+ val loaded = publicPaywallContent?.invoke(delivery) ?: IndianaPaywallContent(configuration).resolve(delivery)
171
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
172
+ IndianaSchema.validate("paywallResolution", loaded)
173
+ val active = current?.optJSONObject("paywall") ?: throw IndianaClientException("invalid_response")
174
+ val latest = active.getJSONObject("resolution")
175
+ if (!IndianaPaywallContent.validatesHydration(loaded, delivery) || active.getString("receiptId") != paywall.getString("receiptId") || latest.getString("id") != delivery.getString("id") || !IndianaSchema.equal(latest.getJSONObject("content"), delivery.getJSONObject("content"))) throw IndianaClientException("invalid_response")
176
+ IndianaPaywallContent.applyPresentation(loaded, latest)
177
+ return synchronized(responseState) {
178
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
179
+ publicPresentation.set(JSONObject().put("receiptId",active.getString("receiptId")).put("resolution",JSONObject(latest.toString())))
180
+ latest
181
+ }
182
+ }
183
+ private fun presentationButtons(): JSONObject? {
184
+ val active = current?.optJSONObject("paywall") ?: return null
185
+ val cached = publicPresentation.get() ?: return null
186
+ val value = active.getJSONObject("resolution"); val loaded = cached.getJSONObject("resolution")
187
+ if (active.getString("receiptId") != cached.getString("receiptId") || value.getString("id") != loaded.getString("id") || !IndianaSchema.equal(value.getJSONObject("content"), loaded.getJSONObject("content"))) return null
188
+ return loaded.getJSONObject("document").getJSONObject("buttons")
189
+ }
190
+ internal fun trustedPurchase(offered: JSONObject): JSONObject { val operation = response.get()?.optJSONObject("purchase")?.optJSONObject("operation") ?: current?.optJSONObject("paywall")?.optJSONObject("operation");if (operation == null || !IndianaSchema.equal(operation, offered)) throw IndianaClientException("invalid_command");return JSONObject(operation.toString()) }
191
+ private fun https(raw: String): URI { val url = URI(raw);if (url.scheme != "https" || url.host == null || url.userInfo != null) throw IndianaClientException("invalid_command");return url }
192
+ internal fun trustedCheckoutURL(raw: String): URI { val operation = response.get()?.optJSONObject("purchase")?.optJSONObject("operation") ?: current?.optJSONObject("paywall")?.optJSONObject("operation");if (operation?.optString("checkoutUrl") != raw) throw IndianaClientException("invalid_command");return https(raw) }
193
+ internal fun trustedOpen(offered: JSONObject): URI {
194
+ val raw = offered.optString("url");val instruction = current?.optJSONObject("instruction");val buttons = presentationButtons()
195
+ val authoredAction = buttons?.keys()?.asSequence()?.any { key ->
196
+ buttons.optJSONObject(key)?.optJSONObject("action")?.let { it.optString("type") == "open_url" && it.optString("url") == raw } == true
197
+ } ?: false
198
+ val accepted = (instruction?.optString("type") == "open_url" && instruction.optString("url") == raw && instruction.optString("receiptId") == offered.optString("receiptId") && instruction.optString("openIn") == offered.optString("openIn")) || authoredAction
199
+ if (!accepted) throw IndianaClientException("invalid_command");return https(raw)
200
+ }
201
+ internal fun trustedEvent(name: String): String {
202
+ val instruction = current?.optJSONObject("instruction")
203
+ val buttons = presentationButtons()
204
+ val authored = buttons?.keys()?.asSequence()?.any { key -> buttons.optJSONObject(key)?.let { it.optString("type") == "action" && it.optJSONObject("action")?.let { action -> action.optString("type") == "event" && action.optString("event") == name } == true } == true } ?: false
205
+ if (!(instruction?.optString("type") == "event" && instruction.optString("event") == name) && !authored) throw IndianaClientException("invalid_command")
206
+ return name
207
+ }
208
+ }
@@ -0,0 +1,71 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.graphics.Color
6
+ import android.net.Uri
7
+ import android.widget.FrameLayout
8
+ import kotlinx.coroutines.CoroutineScope
9
+ import kotlinx.coroutines.Dispatchers
10
+ import kotlinx.coroutines.SupervisorJob
11
+ import kotlinx.coroutines.cancel
12
+ import kotlinx.coroutines.launch
13
+ import org.json.JSONObject
14
+ import java.net.URI
15
+
16
+ /** Native container for the sole Funnel renderer. No JavaScript interface or credential is exposed. */
17
+ class IndianaFunnelView(private val activity: Activity, val session: IndianaFunnelSession): FrameLayout(activity) {
18
+ var onPurchase: (suspend (JSONObject) -> Unit)? = null
19
+ var onEvent: (suspend (String) -> Unit)? = null
20
+ /** Opens an external page or Paywall link (openIn is same_window or new_window). Return false when it could not be
21
+ * opened; an external page then takes its failed route. Default: the system browser. */
22
+ var onOpenUrl: (suspend (URI, String) -> Boolean)? = null
23
+ var onComplete: ((JSONObject) -> Unit)? = null
24
+ var onDismiss: (() -> Unit)? = null
25
+ var onError: ((Exception) -> Unit)? = null
26
+ private val work = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
27
+ private val configurationRelay = IndianaFunnelConfigurationRelay(session.hostConfiguration())
28
+ private var stopped = false; private var completed = false; private var dismissed = false
29
+ private val host = IndianaBundledWebView(activity, "Funnel", work, ::dispatch) { onError?.invoke(it) }
30
+ init {
31
+ setBackgroundColor(Color.TRANSPARENT)
32
+ addView(host.view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
33
+ session.onInvalidation { post { destroy() } }
34
+ host.start()
35
+ }
36
+ private suspend fun dispatch(method: String, raw: Any?): Any? = when (method) {
37
+ "ready" -> { send(JSONObject().put("event", "init").put("scope", JSONObject().put("projectId", session.hostConfiguration().scope.projectId.toString()).put("environment", session.hostConfiguration().scope.environment.value)).put("apiOrigin", session.hostConfiguration().apiOrigin)); null }
38
+ "pendingCommand" -> session.hostPendingCommand()
39
+ "configuration" -> configurationRelay.read(raw)
40
+ "command" -> session.hostCommand(raw as? JSONObject ?: throw IndianaClientException("invalid_command"))
41
+ "paywallContent" -> session.hostPaywallContent(raw as? JSONObject ?: throw IndianaClientException("invalid_command"))
42
+ "purchase" -> { val operation = (raw as? JSONObject)?.optJSONObject("operation") ?: throw IndianaClientException("invalid_command");val trusted = session.trustedPurchase(operation);val handler = onPurchase ?: throw IndianaClientException("unsupported_capability");handler(trusted);null }
43
+ "checkout" -> { val value = (raw as? JSONObject)?.optString("url") ?: throw IndianaClientException("invalid_command");val url = session.trustedCheckoutURL(value);activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url.toString())).addCategory(Intent.CATEGORY_BROWSABLE));null }
44
+ "open" -> {
45
+ val offered = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
46
+ val url = session.trustedOpen(offered);val openIn = offered.optString("openIn", "same_window")
47
+ onOpenUrl?.invoke(url, openIn) ?: try {
48
+ activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url.toString())).addCategory(Intent.CATEGORY_BROWSABLE));true
49
+ } catch (_: android.content.ActivityNotFoundException) { false }
50
+ }
51
+ "event" -> { val name = (raw as? JSONObject)?.optString("event") ?: throw IndianaClientException("invalid_command");val trusted = session.trustedEvent(name);val handler = onEvent ?: throw IndianaClientException("unsupported_capability");handler(trusted);null }
52
+ "complete" -> {
53
+ val run = session.current ?: throw IndianaClientException("run_required")
54
+ if (run.getString("status") != "completed" || !run.isNull("instruction")) throw IndianaClientException("invalid_command")
55
+ if (!completed) { completed = true; onComplete?.invoke(run) }; null
56
+ }
57
+ "dismiss" -> { if (!dismissed) { dismissed = true; onDismiss?.invoke() }; null }
58
+ else -> throw IndianaClientException("invalid_command")
59
+ }
60
+ private fun send(value: JSONObject) { if (!stopped) host.send(value) }
61
+ fun refresh() { send(JSONObject().put("event", "refresh")) }
62
+ override fun onWindowFocusChanged(hasWindowFocus: Boolean) { super.onWindowFocusChanged(hasWindowFocus); if (hasWindowFocus) refresh() }
63
+ /** Programmatic destruction removes private UI; it does not report business completion. */
64
+ fun destroy() {
65
+ if (stopped) return
66
+ send(JSONObject().put("event", "invalidated")); stopped = true
67
+ host.destroy(); work.cancel(); removeView(host.view)
68
+ if (!dismissed) { dismissed = true; onDismiss?.invoke() }
69
+ }
70
+ fun close() { work.launch { try { session.close() } catch (error: Exception) { onError?.invoke(error) } finally { destroy() } } }
71
+ }
@@ -0,0 +1,16 @@
1
+ // Generated by scripts/sdk/generate-native-protocol.mjs. Do not edit.
2
+ package ai.tinkerland.indiana
3
+ enum class IndianaEnvironment(val value: String) { SANDBOX("sandbox"), LIVE("live") }
4
+ internal object IndianaProtocol {
5
+ val wireProtocol = "0.9"
6
+ val uuidPattern = Regex("^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)\$")
7
+ val utcPattern = Regex("^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))\$")
8
+ val scopeKeys = setOf("projectId", "environment")
9
+ val runKeys = setOf("id", "source", "revision", "visit", "nodeId", "nodeType", "status", "stopped", "canBack", "authenticated", "locale", "answers", "parameters", "variables", "output", "screen", "paywall", "instruction", "pendingRequest", "runExpiresAt", "credentialExpiresAt")
10
+ val runRequiredKeys = setOf("id", "source", "revision", "visit", "nodeId", "nodeType", "status", "stopped", "canBack", "authenticated", "locale", "answers", "parameters", "variables", "output", "screen", "paywall", "instruction", "pendingRequest", "runExpiresAt", "credentialExpiresAt")
11
+ val nodeTypes = setOf("entry", "screen", "condition", "split", "feature", "action", "external_page", "wait", "end", "offering", "coupon", "coupons", "output")
12
+ val runStatuses = setOf("running", "waiting", "completed", "closed", "ineligible", "failed")
13
+ val nonCanonicalLocaleLanguages = setOf("aam", "aar", "abk", "adp", "afr", "agp", "ais", "ajp", "ajt", "aju", "aka", "alb", "als", "amh", "ara", "arb", "arg", "arm", "asd", "asm", "aue", "ava", "ave", "aym", "ayr", "ayx", "aze", "azj", "bak", "bam", "baq", "baz", "bcc", "bcl", "bel", "ben", "bgm", "bhk", "bic", "bih", "bis", "bjd", "bjq", "bkb", "blg", "bod", "bos", "bre", "btb", "bul", "bur", "bxk", "bxr", "cat", "ccq", "ces", "cha", "che", "chi", "chu", "chv", "cjr", "cka", "cld", "cls", "cmk", "cmn", "cnr", "cor", "cos", "coy", "cqu", "cre", "cwd", "cym", "cze", "daf", "dan", "dap", "deu", "dgo", "dhd", "dik", "diq", "dit", "div", "djl", "dkl", "drh", "drr", "drw", "dud", "duj", "dut", "dwl", "dzo", "ekk", "ell", "elp", "emk", "eng", "epo", "esk", "est", "eus", "ewe", "fao", "fas", "fat", "fij", "fin", "fra", "fre", "fry", "fuc", "ful", "gav", "gaz", "gbc", "gbo", "geo", "ger", "gfx", "ggn", "ggo", "ggr", "gio", "gla", "gle", "glg", "gli", "glv", "gno", "gom", "gre", "grn", "gti", "gug", "guj", "guv", "gya", "hat", "hau", "hbs", "hdn", "hea", "heb", "her", "him", "hin", "hmo", "hrr", "hrv", "hun", "hye", "ibi", "ibo", "ice", "ido", "iii", "ike", "iku", "ile", "ill", "ilw", "in", "ina", "ind", "ipk", "isl", "ita", "iw", "izi", "jar", "jav", "jaw", "jeg", "ji", "jpn", "jw", "kal", "kan", "kas", "kat", "kau", "kaz", "kdv", "kgc", "kgd", "kgh", "kgm", "khk", "khm", "kik", "kin", "kir", "kmr", "knc", "kng", "koj", "kom", "kon", "kor", "kpp", "kpv", "krm", "ktr", "kua", "kur", "kvs", "kwq", "kxe", "kxl", "kzh", "kzj", "kzt", "lak", "lao", "lat", "lav", "lbk", "leg", "lii", "lim", "lin", "lit", "llo", "lmm", "ltz", "lub", "lug", "lvs", "mac", "mah", "mal", "mao", "mar", "may", "meg", "mgx", "mhr", "mkd", "mlg", "mlt", "mnk", "mnt", "mo", "mof", "mol", "mon", "mri", "msa", "mst", "mup", "mwd", "mwj", "mya", "myd", "myt", "nad", "nau", "nav", "nbf", "nbl", "nbx", "ncp", "nde", "ndo", "nep", "nld", "nln", "nlr", "nno", "nns", "nnx", "nob", "nom", "noo", "nor", "npi", "nts", "nxu", "nya", "oci", "ojg", "oji", "ori", "orm", "ory", "oss", "oun", "pan", "pat", "pbu", "pcr", "per", "pes", "pli", "plt", "pmc", "pmk", "pmu", "pnb", "pol", "por", "ppa", "ppr", "prp", "prs", "pry", "pus", "puz", "que", "quz", "rmr", "rmy", "roh", "ron", "rum", "run", "rus", "sag", "san", "sap", "sca", "scc", "scr", "sgl", "sh", "sin", "skk", "slk", "slo", "slv", "smd", "sme", "smo", "sna", "snb", "snd", "som", "sot", "spa", "spy", "sqi", "src", "srd", "srp", "ssw", "sul", "sum", "sun", "swa", "swc", "swe", "swh", "szd", "tah", "tam", "tat", "tdu", "tel", "tgg", "tgk", "tgl", "tha", "thc", "thw", "thx", "tib", "tid", "tie", "tir", "tkk", "tl", "tlw", "tmk", "tmp", "tne", "tnf", "ton", "tpw", "tsf", "tsn", "tso", "ttq", "tuk", "tur", "twi", "uig", "ukr", "umu", "unp", "uok", "urd", "uzb", "uzn", "ven", "vie", "vol", "wel", "wgw", "wit", "wiw", "wln", "wol", "xba", "xho", "xia", "xkh", "xpe", "xrq", "xsj", "xsl", "xss", "ybd", "ydd", "yen", "yid", "yiy", "yma", "ymt", "yor", "yos", "yuu", "zai", "zha", "zho", "zir", "zkb", "zsm", "zul", "zyb")
14
+ val nonCanonicalLocaleScripts = setOf("Qaai")
15
+ val nonCanonicalLocaleRegions = setOf("AN", "BU", "CS", "CT", "DD", "DY", "FQ", "FX", "HV", "JT", "MI", "NH", "NQ", "NT", "PC", "PU", "PZ", "QU", "RH", "SU", "TP", "UK", "VD", "WK", "YD", "YU", "ZR", "004", "008", "010", "012", "016", "020", "024", "028", "031", "032", "036", "040", "044", "048", "050", "051", "052", "056", "060", "062", "064", "068", "070", "072", "074", "076", "084", "086", "090", "092", "096", "100", "104", "108", "112", "116", "120", "124", "132", "136", "140", "144", "148", "152", "156", "158", "162", "166", "170", "172", "174", "175", "178", "180", "184", "188", "191", "192", "196", "200", "203", "204", "208", "212", "214", "218", "222", "226", "230", "231", "232", "233", "234", "238", "239", "242", "246", "248", "249", "250", "254", "258", "260", "262", "266", "268", "270", "275", "276", "278", "280", "288", "292", "296", "300", "304", "308", "312", "316", "320", "324", "328", "332", "334", "336", "340", "344", "348", "352", "356", "360", "364", "368", "372", "376", "380", "384", "388", "392", "398", "400", "404", "408", "410", "414", "417", "418", "422", "426", "428", "430", "434", "438", "440", "442", "446", "450", "454", "458", "462", "466", "470", "474", "478", "480", "484", "492", "496", "498", "499", "500", "504", "508", "512", "516", "520", "524", "528", "530", "531", "532", "533", "534", "535", "536", "540", "548", "554", "558", "562", "566", "570", "574", "578", "580", "581", "582", "583", "584", "585", "586", "591", "598", "600", "604", "608", "612", "616", "620", "624", "626", "630", "634", "638", "642", "643", "646", "652", "654", "659", "660", "662", "663", "666", "670", "674", "678", "682", "686", "688", "690", "694", "702", "703", "704", "705", "706", "710", "716", "720", "724", "728", "729", "732", "736", "740", "744", "748", "752", "756", "760", "762", "764", "768", "772", "776", "780", "784", "788", "792", "795", "796", "798", "800", "804", "807", "810", "818", "826", "830", "831", "832", "833", "834", "840", "850", "854", "858", "860", "862", "876", "882", "886", "887", "890", "891", "894", "958", "959", "960", "962", "963", "964", "965", "966", "967", "968", "969", "970", "971", "972", "973", "974", "975", "976", "977", "978", "979", "980", "981", "982", "983", "984", "985", "986", "987", "988", "989", "990", "991", "992", "993", "994", "995", "996", "997", "998", "999")
16
+ }
@@ -0,0 +1,165 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import org.json.JSONObject
6
+ import java.math.BigInteger
7
+ import com.android.billingclient.api.*
8
+ import kotlinx.coroutines.suspendCancellableCoroutine
9
+ import kotlinx.coroutines.withContext
10
+ import kotlinx.coroutines.Dispatchers
11
+ import kotlin.coroutines.resume
12
+ import kotlin.coroutines.resumeWithException
13
+
14
+ class IndianaStoreException(val code: String) : Exception(code)
15
+ data class GooglePurchaseEvidence(val productId: String, val purchaseToken: String, val pending: Boolean, val accountBinding: String?, val intentBinding: String?) {
16
+ override fun toString()="GooglePurchaseEvidence(productId=$productId, pending=$pending, purchaseToken=[redacted])"
17
+ }
18
+ data class GooglePricePhase(val displayPrice: String, val currency: String, val micros: String, val billingPeriod: String, val billingCycleCount: Int, val recurrenceMode: Int)
19
+ data class GoogleQuote(val productId: String, val phases: List<GooglePricePhase>, val offerToken: String?, val replacementPolicy:String? = null)
20
+ class GoogleReplacement(val sourceProductId:String,val oldPurchaseToken:String) {
21
+ override fun toString() = "GoogleReplacement(sourceProductId=$sourceProductId, oldPurchaseToken=[redacted])"
22
+ }
23
+ internal fun googleReplacement(instruction:JSONObject,purchases:List<GooglePurchaseEvidence>):GoogleReplacement {
24
+ val replacement=instruction.getJSONObject("replacement")
25
+ if(replacement.optString("policy") != "google_full_price") throw IndianaClientException("invalid_response")
26
+ val source=purchases.singleOrNull { it.productId == text(replacement,"sourceProductId") && it.intentBinding == text(replacement,"sourceOrderId") && it.accountBinding == text(instruction,"obfuscatedAccountId") && !it.pending } ?: throw IndianaClientException("source_purchase_unavailable")
27
+ return GoogleReplacement(source.productId,source.purchaseToken)
28
+ }
29
+ data class GoogleStoreTerms(val productKind: String, val offerKind: String, val currency: String, val catalogMinor: Long, val discountMinor: Long, val trialDays: Int?, val seats: List<Pair<String, Int>>, val replacement:GoogleReplacement? = null) {
30
+ companion object {
31
+ fun parse(raw: JSONObject): GoogleStoreTerms = safe {
32
+ val productKind = text(raw,"productKind");require(productKind in setOf("membership","lifetime","credit_pack"))
33
+ val kind = text(raw, "offerKind"); require(kind in setOf("none", "introductory", "promotional"))
34
+ val currency = text(raw, "currency"); require(currency.matches(Regex("[A-Z]{3}")))
35
+ fun amount(key: String): Long { val number = raw.get(key) as? Number ?: throw IllegalArgumentException(); val integer = number.toLong(); require(integer in 0..100000000 && number.toDouble() == integer.toDouble()); return integer }
36
+ val catalog = amount("catalogAmountMinor"); val discount = amount("discountMinor"); require(discount <= catalog)
37
+ require(raw.has("trialDays")); val trial = if (raw.isNull("trialDays")) null else amount("trialDays").toInt().also { require(it in 1..730) }
38
+ require(raw.has("applePromotionalOffer") && raw.isNull("applePromotionalOffer"))
39
+ val bundles = raw.getJSONArray("seatBundle"); require(bundles.length() <= 20)
40
+ val seats = (0 until bundles.length()).map { index ->
41
+ val seat = bundles.getJSONObject(index); keys(seat, setOf("key", "quantity")); val key = text(seat, "key"); require(key.matches(Regex("[a-z][a-z0-9_]{0,63}")))
42
+ val quantity = seat.get("quantity") as? Number ?: throw IllegalArgumentException(); require(quantity.toInt() in 1..10000 && quantity.toDouble() == quantity.toInt().toDouble()); key to quantity.toInt()
43
+ }; require(seats.map { it.first }.distinct().size == seats.size)
44
+ require(trial == null || discount == 0L); require(kind != "none" || (trial == null && discount == 0L))
45
+ return@safe GoogleStoreTerms(productKind, kind, currency, catalog, discount, trial, seats)
46
+ }
47
+ }
48
+ fun validate(quote: GoogleQuote): GoogleQuote {
49
+ val scale = if (currency in setOf("JPY", "KRW", "VND")) BigInteger.valueOf(1000000) else BigInteger.valueOf(10000)
50
+ fun minor(phase: GooglePricePhase): Long {
51
+ if (phase.currency != currency) throw IndianaStoreException("commercial_terms_changed")
52
+ val micros = phase.micros.toBigInteger(); if (micros.signum() < 0 || micros.remainder(scale) != BigInteger.ZERO) throw IndianaStoreException("commercial_terms_changed")
53
+ return micros.divide(scale).longValueExact()
54
+ }
55
+ val phases = quote.phases
56
+ if (phases.isNotEmpty() && ((productKind == "membership") != phases.last().billingPeriod.isNotEmpty())) throw IndianaStoreException("commercial_terms_changed")
57
+ if (phases.isEmpty() || minor(phases.last()) != catalogMinor) throw IndianaStoreException("commercial_terms_changed")
58
+ if (offerKind == "none") { if (phases.size != 1) throw IndianaStoreException("unexpected_store_offer") }
59
+ else {
60
+ if (phases.size != 2 || phases.first().billingCycleCount != 1 || phases.first().recurrenceMode != ProductDetails.RecurrenceMode.FINITE_RECURRING) throw IndianaStoreException("unsupported_offer_duration")
61
+ val first = phases.first()
62
+ if (trialDays != null) {
63
+ val period = Regex("P([0-9]+)([DW])").matchEntire(first.billingPeriod) ?: throw IndianaStoreException("unsupported_trial_period")
64
+ val days = period.groupValues[1].toLong() * if (period.groupValues[2] == "W") 7 else 1
65
+ if (minor(first) != 0L || days != trialDays.toLong()) throw IndianaStoreException("commercial_terms_changed")
66
+ } else if (minor(first) != catalogMinor - discountMinor || first.billingPeriod != phases.last().billingPeriod) throw IndianaStoreException("commercial_terms_changed")
67
+ }
68
+ return quote
69
+ }
70
+ fun description(quote: GoogleQuote): String = quote.phases.joinToString("\nThen ") { phase ->
71
+ phase.displayPrice + if (phase.billingPeriod.isEmpty()) "" else " / " + phase.billingPeriod + if (phase.billingCycleCount > 0) " × " + phase.billingCycleCount else ""
72
+ } + (if (quote.phases.last().billingPeriod.isNotEmpty()) "\nAuto-renews until canceled in Google Play." else "") + (if (seats.isNotEmpty()) "\nIncluded seats: " + seats.joinToString(", ") { "${it.first}: ${it.second}" } else "")
73
+ }
74
+ sealed interface GoogleStoreResult {
75
+ data class Purchased(val evidence: GooglePurchaseEvidence): GoogleStoreResult
76
+ data object Canceled: GoogleStoreResult
77
+ data class Failed(val code: String): GoogleStoreResult
78
+ }
79
+
80
+ /** The backend owns acknowledge/consume. Purchase callbacks never grant customer access. */
81
+ class GoogleStorefront(context: Context, private val onResult: (GoogleStoreResult) -> Unit) : IndianaGoogleStore {
82
+ private val client = BillingClient.newBuilder(context.applicationContext)
83
+ .setListener { result, purchases ->
84
+ when (result.responseCode) {
85
+ BillingClient.BillingResponseCode.OK -> if (purchases.isNullOrEmpty()) onResult(GoogleStoreResult.Failed("invalid_evidence")) else purchases.forEach { purchase ->
86
+ if (purchase.products.size != 1 || purchase.quantity != 1 || purchase.purchaseState !in listOf(Purchase.PurchaseState.PURCHASED, Purchase.PurchaseState.PENDING)) onResult(GoogleStoreResult.Failed("unsupported_purchase"))
87
+ else onResult(GoogleStoreResult.Purchased(GooglePurchaseEvidence(purchase.products.single(),purchase.purchaseToken,purchase.purchaseState == Purchase.PurchaseState.PENDING,purchase.accountIdentifiers?.obfuscatedAccountId,purchase.accountIdentifiers?.obfuscatedProfileId)))
88
+ }
89
+ BillingClient.BillingResponseCode.USER_CANCELED -> onResult(GoogleStoreResult.Canceled)
90
+ else -> onResult(GoogleStoreResult.Failed("store_unavailable"))
91
+ }
92
+ }
93
+ .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
94
+ .enableAutoServiceReconnection().build()
95
+
96
+ override suspend fun connect() { if(client.isReady)return; suspendCancellableCoroutine<Unit> { continuation ->
97
+ client.startConnection(object : BillingClientStateListener {
98
+ override fun onBillingSetupFinished(result: BillingResult) {
99
+ if (!continuation.isActive) return
100
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) continuation.resume(Unit)
101
+ else continuation.resumeWithException(IndianaStoreException("store_unavailable"))
102
+ }
103
+ override fun onBillingServiceDisconnected() {}
104
+ })
105
+ } }
106
+ private suspend fun details(productId: String, membership: Boolean): ProductDetails = suspendCancellableCoroutine { continuation ->
107
+ val request = QueryProductDetailsParams.newBuilder().setProductList(listOf(
108
+ QueryProductDetailsParams.Product.newBuilder().setProductId(productId)
109
+ .setProductType(if (membership) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP).build()
110
+ )).build()
111
+ client.queryProductDetailsAsync(request) { result, products ->
112
+ if (!continuation.isActive) return@queryProductDetailsAsync
113
+ val product = products.productDetailsList.firstOrNull { it.productId == productId }
114
+ if (result.responseCode == BillingClient.BillingResponseCode.OK && product != null) continuation.resume(product)
115
+ else continuation.resumeWithException(IndianaStoreException("product_unavailable"))
116
+ }
117
+ }
118
+ override suspend fun quote(productId: String, basePlanId: String?, offerId: String?): GoogleQuote = quote(details(productId,basePlanId != null),basePlanId,offerId)
119
+ private fun quote(product: ProductDetails, basePlanId: String?, offerId: String?): GoogleQuote {
120
+ if (basePlanId == null) {
121
+ val offers = product.oneTimePurchaseOfferDetailsList?.filter { it.offerId == offerId } ?: emptyList()
122
+ if(offers.size != 1) throw IndianaStoreException("unsupported_offer")
123
+ val offer = offers.single()
124
+ if (offer.rentalDetails != null || offer.preorderDetails != null || offer.limitedQuantityInfo != null || offer.offerId != offerId) throw IndianaStoreException("unsupported_offer")
125
+ return GoogleQuote(product.productId,listOf(GooglePricePhase(offer.formattedPrice,offer.priceCurrencyCode,offer.priceAmountMicros.toString(),"",1,ProductDetails.RecurrenceMode.NON_RECURRING)),offer.offerToken)
126
+ }
127
+ val offer = product.subscriptionOfferDetails?.firstOrNull { it.basePlanId == basePlanId && it.offerId == offerId }
128
+ ?: throw IndianaStoreException("offer_unavailable")
129
+ if (offer.installmentPlanDetails != null) throw IndianaStoreException("unsupported_offer")
130
+ val phases = offer.pricingPhases.pricingPhaseList
131
+ if (phases.isEmpty() || phases.size>20 || phases.last().recurrenceMode != ProductDetails.RecurrenceMode.INFINITE_RECURRING) throw IndianaStoreException("unsupported_offer")
132
+ return GoogleQuote(product.productId,phases.map { GooglePricePhase(it.formattedPrice,it.priceCurrencyCode,it.priceAmountMicros.toString(),it.billingPeriod,it.billingCycleCount,it.recurrenceMode) },offer.offerToken)
133
+ }
134
+ override suspend fun purchase(activity: Activity, productId: String, basePlanId: String?, offerId: String?, obfuscatedAccountId: String, obfuscatedProfileId: String, terms: GoogleStoreTerms) {
135
+ if (obfuscatedAccountId.length !in 1..64) throw IndianaStoreException("invalid_account_binding")
136
+ val product = details(productId, basePlanId != null)
137
+ val quote = terms.validate(quote(product, basePlanId, offerId))
138
+ val item = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product)
139
+ quote.offerToken?.let { item.setOfferToken(it) }
140
+ val flowBuilder = BillingFlowParams.newBuilder()
141
+ terms.replacement?.let { replacement ->
142
+ if (basePlanId == null || terms.productKind != "membership" || replacement.oldPurchaseToken.isEmpty()) throw IndianaStoreException("invalid_replacement")
143
+ item.setSubscriptionProductReplacementParams(BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.newBuilder()
144
+ .setOldProductId(replacement.sourceProductId)
145
+ .setReplacementMode(BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode.CHARGE_FULL_PRICE).build())
146
+ flowBuilder.setSubscriptionUpdateParams(BillingFlowParams.SubscriptionUpdateParams.newBuilder().setOldPurchaseToken(replacement.oldPurchaseToken).build())
147
+ }
148
+ val flow = flowBuilder.setProductDetailsParamsList(listOf(item.build()))
149
+ .setObfuscatedAccountId(obfuscatedAccountId).setObfuscatedProfileId(obfuscatedProfileId).build()
150
+ val result = withContext(Dispatchers.Main.immediate) { client.launchBillingFlow(activity, flow) }
151
+ if (result.responseCode != BillingClient.BillingResponseCode.OK) throw IndianaStoreException("purchase_not_started")
152
+ }
153
+ override suspend fun scanPurchases(membership: Boolean): List<GooglePurchaseEvidence> = suspendCancellableCoroutine { continuation ->
154
+ val query = QueryPurchasesParams.newBuilder()
155
+ .setProductType(if (membership) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP).build()
156
+ client.queryPurchasesAsync(query) { result, purchases ->
157
+ if (!continuation.isActive) return@queryPurchasesAsync
158
+ if (result.responseCode != BillingClient.BillingResponseCode.OK) continuation.resumeWithException(IndianaStoreException("store_unavailable"))
159
+ else continuation.resume(purchases.filter { it.products.size == 1 && it.quantity == 1 }.map {
160
+ GooglePurchaseEvidence(it.products.single(), it.purchaseToken, it.purchaseState == Purchase.PurchaseState.PENDING,it.accountIdentifiers?.obfuscatedAccountId,it.accountIdentifiers?.obfuscatedProfileId)
161
+ })
162
+ }
163
+ }
164
+ override fun close() { client.endConnection() }
165
+ }
@@ -0,0 +1,43 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import kotlinx.coroutines.Dispatchers
4
+ import kotlinx.coroutines.withContext
5
+ import org.json.JSONObject
6
+ import java.net.URI
7
+ import java.net.HttpURLConnection
8
+ import java.io.ByteArrayOutputStream
9
+
10
+ /** Override baseUrl for test services; Sandbox selects business scope within that service. */
11
+ data class IndianaConfiguration(val baseUrl: String = "https://api.tinker.cash",val scope: IndianaScope,val timeoutMs: Int=15_000) {
12
+ internal val apiOrigin: String get() {
13
+ val url = URI(baseUrl)
14
+ return URI(url.scheme.lowercase(), null, url.host.lowercase(), if (url.port == 443) -1 else url.port, null, null, null).toString()
15
+ }
16
+ init { val url=URI(baseUrl);require(url.scheme=="https"&&url.host!=null&&url.rawUserInfo==null&&url.rawQuery==null&&url.rawFragment==null&&timeoutMs in 1..120_000) }
17
+ }
18
+ internal interface IndianaHTTP { suspend fun send(path: String,method: String,authorization: String,body: JSONObject?): JSONObject }
19
+ internal fun requireCurrentProtocol(value: String?) {
20
+ if (value != IndianaProtocol.wireProtocol) throw IndianaClientException("invalid_response")
21
+ }
22
+ internal class IndianaURLTransport(private val configuration: IndianaConfiguration): IndianaHTTP {
23
+ override suspend fun send(path: String,method: String,authorization: String,body: JSONObject?): JSONObject = withContext(Dispatchers.IO) {
24
+ require(!path.contains("..")&&!path.contains("?")&&!path.contains("#")&&!path.startsWith("/"))
25
+ val connection=URI(configuration.baseUrl.trimEnd('/')+"/api/v1/"+path).toURL().openConnection() as HttpURLConnection
26
+ try {
27
+ connection.requestMethod=method;connection.instanceFollowRedirects=false;connection.connectTimeout=configuration.timeoutMs;connection.readTimeout=configuration.timeoutMs;connection.useCaches=false
28
+ if(authorization.isNotEmpty()) connection.setRequestProperty("Authorization","Bearer $authorization")
29
+ connection.setRequestProperty("Content-Type","application/json")
30
+ connection.setRequestProperty("X-Indiana-Project",configuration.scope.projectId.toString());connection.setRequestProperty("X-Indiana-Environment",configuration.scope.environment.value)
31
+ connection.setRequestProperty("X-Indiana-Protocol",IndianaProtocol.wireProtocol)
32
+ connection.setRequestProperty("X-Indiana-SDK-Version",IndianaSDK.version)
33
+ if(body!=null){connection.doOutput=true;val bytes=body.toString().toByteArray(Charsets.UTF_8);connection.setFixedLengthStreamingMode(bytes.size);connection.outputStream.use {it.write(bytes)}}
34
+ if(connection.responseCode !in 200..299)throw IndianaClientException("request_failed_${connection.responseCode}")
35
+ requireCurrentProtocol(connection.getHeaderField("X-Indiana-Protocol"))
36
+ if((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0)>2_097_152)throw IndianaClientException("invalid_response")
37
+ val output=ByteArrayOutputStream()
38
+ connection.inputStream.use {input->val buffer=ByteArray(8192);while(true){val size=input.read(buffer);if(size<0)break;if(output.size()+size>2_097_152)throw IndianaClientException("invalid_response");output.write(buffer,0,size)}}
39
+ safe{JSONObject(output.toString("UTF-8"))}
40
+ } catch(e:IndianaClientException){throw e}catch(_:Exception){throw IndianaClientException("temporary_unavailable")}
41
+ finally {connection.disconnect()}
42
+ }
43
+ }