@tinkerlandai/indiana-react-native 0.9.2 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IndianaReactNative.podspec +19 -0
- package/README.md +62 -5
- package/THIRD_PARTY_NOTICES.md +74 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
- package/dist/index.cjs +17 -1
- package/dist/index.d.cts +96889 -6007
- package/dist/index.d.ts +96889 -6007
- package/dist/index.js +17 -1
- package/ios/IndianaBridge.m +11 -0
- package/ios/IndianaBridge.swift +275 -0
- package/native-sdk/android/assets/indiana/index.html +268 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +197 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/MerchantCommerceSemantics.kt +38 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
- package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
- package/native-sdk/android/resources/renderer-data-url.txt +1 -0
- package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
- package/native-sdk/ios/AppleStorefront.swift +218 -0
- package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
- package/native-sdk/ios/BundledWebView.swift +87 -0
- package/native-sdk/ios/ContentSession.swift +48 -0
- package/native-sdk/ios/ContentView.swift +51 -0
- package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
- package/native-sdk/ios/CustomerResponseSemantics.swift +236 -0
- package/native-sdk/ios/CustomerSemantics.swift +145 -0
- package/native-sdk/ios/CustomerSession.swift +129 -0
- package/native-sdk/ios/FeatureClient.swift +86 -0
- package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
- package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
- package/native-sdk/ios/FunnelSession.swift +232 -0
- package/native-sdk/ios/FunnelView.swift +125 -0
- package/native-sdk/ios/GeneratedProtocol.swift +16 -0
- package/native-sdk/ios/HTTPTransport.swift +74 -0
- package/native-sdk/ios/IndianaClient.swift +601 -0
- package/native-sdk/ios/MerchantCommerceSemantics.swift +51 -0
- package/native-sdk/ios/NativePendingKeychain.swift +52 -0
- package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
- package/native-sdk/ios/NativeStoreOffer.swift +34 -0
- package/native-sdk/ios/PaywallContent.swift +75 -0
- package/native-sdk/ios/PaywallSession.swift +124 -0
- package/native-sdk/ios/PaywallView.swift +87 -0
- package/native-sdk/ios/Protocol.swift +49 -0
- package/native-sdk/ios/PushInstallation.swift +154 -0
- package/native-sdk/ios/RendererNavigation.swift +8 -0
- package/native-sdk/ios/Resources.swift +15 -0
- package/native-sdk/ios/SDKVersion.swift +4 -0
- package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
- package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
- package/native-sdk/ios/WebView/index.html +268 -0
- package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
- package/native-sdk/ios/WireSchema.swift +107 -0
- package/native-sdk/ios/funnel-wire-schemas.json +1 -0
- package/package.json +8 -3
- 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.10"
|
|
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
|
+
}
|