@tinkerlandai/indiana-react-native 0.9.2 → 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.
- package/IndianaReactNative.podspec +19 -0
- package/README.md +61 -4
- 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 +57408 -604
- package/dist/index.d.ts +57408 -604
- 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 +193 -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/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 +232 -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/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,55 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
import java.math.BigDecimal
|
|
5
|
+
import java.util.UUID
|
|
6
|
+
|
|
7
|
+
/** Current immutable Stripe authorization terms contain no payment credential. */
|
|
8
|
+
internal object BillingAuthorizationTerms {
|
|
9
|
+
fun validate(terms: JSONObject) {
|
|
10
|
+
fun invalid(): Nothing = throw IndianaClientException("invalid_response")
|
|
11
|
+
fun text(value: JSONObject, key: String): String = value.opt(key) as? String ?: invalid()
|
|
12
|
+
fun keys(value: JSONObject, expected: Set<String>) { if (value.keys().asSequence().toSet() != expected) invalid() }
|
|
13
|
+
fun quantity(value: Any?, positive: Boolean = false): BigDecimal {
|
|
14
|
+
val raw = value as? String ?: invalid()
|
|
15
|
+
if (!raw.matches(Regex("(0|[1-9][0-9]{0,11})(\\.[0-9]{1,6})?"))) invalid()
|
|
16
|
+
val amount = raw.toBigDecimal(); if (positive && amount <= BigDecimal.ZERO) invalid(); return amount
|
|
17
|
+
}
|
|
18
|
+
fun amount(key: String) { if (!text(terms, key).matches(Regex("[1-9][0-9]{0,14}"))) invalid() }
|
|
19
|
+
val kind = text(terms, "kind"); val currency = text(terms, "currency")
|
|
20
|
+
if (kind !in setOf("auto_topup", "postpaid") || !currency.matches(Regex("[A-Z]{3}"))) invalid()
|
|
21
|
+
listOf("productVersionId", "internal_feature_id").forEach { UUID.fromString(text(terms, it)) }
|
|
22
|
+
val base=setOf("kind","productVersionId","internal_feature_id","currency")
|
|
23
|
+
if(kind=="auto_topup") {
|
|
24
|
+
keys(terms,base+setOf("threshold","quantity","purchaseAmountMinor","purchaseUnits")+if(terms.has("purchaseLimit"))setOf("purchaseLimit")else emptySet())
|
|
25
|
+
if(!text(terms,"threshold").matches(Regex("^-?(0|[1-9][0-9]{0,11})(\\.[0-9]{1,6})?$")))invalid()
|
|
26
|
+
if(terms.has("purchaseLimit")) {
|
|
27
|
+
val limit=terms.optJSONObject("purchaseLimit")?:invalid();keys(limit,setOf("interval","intervalCount","limit"))
|
|
28
|
+
val count=limit.opt("intervalCount")as?Number?:invalid();val cap=limit.opt("limit")as?Number?:invalid()
|
|
29
|
+
if(text(limit,"interval") !in setOf("hour","day","week","month") || count.toDouble() !in 1.0..12.0 || count.toDouble()!=count.toInt().toDouble() || cap.toDouble() !in 1.0..1000000.0 || cap.toDouble()!=cap.toInt().toDouble())invalid()
|
|
30
|
+
}
|
|
31
|
+
if(quantity(terms.opt("quantity"),true)<BigDecimal.ONE)invalid()
|
|
32
|
+
quantity(terms.opt("purchaseUnits"),true);amount("purchaseAmountMinor");return
|
|
33
|
+
}
|
|
34
|
+
amount("maxAmountMinorPerPeriod")
|
|
35
|
+
keys(terms, base + setOf("maxAmountMinorPerPeriod","maxOutstandingMinor", "cashPrice")); amount("maxOutstandingMinor")
|
|
36
|
+
val price = terms.optJSONObject("cashPrice") ?: invalid(); keys(price, setOf("currency", "pricing") + if(price.has("billingCycle")) setOf("billingCycle") else emptySet())
|
|
37
|
+
if (text(price, "currency") != currency) invalid()
|
|
38
|
+
if(price.has("billingCycle")) {
|
|
39
|
+
val cycle=price.optJSONObject("billingCycle") ?: invalid();keys(cycle,setOf("interval","count","anchor"))
|
|
40
|
+
val count=cycle.opt("count") as? Number ?: invalid()
|
|
41
|
+
if(text(cycle,"interval") !in setOf("month","year") || text(cycle,"anchor") !in setOf("membership","calendar") || count.toDouble() !in 1.0..12.0 || count.toDouble() != count.toInt().toDouble()) invalid()
|
|
42
|
+
}
|
|
43
|
+
val pricing = price.optJSONObject("pricing") ?: invalid(); quantity(pricing.opt("units"), true)
|
|
44
|
+
val pricingKind = text(pricing, "kind")
|
|
45
|
+
if (pricingKind == "flat") { keys(pricing, setOf("kind", "units", "unitAmountMinor")); quantity(pricing.opt("unitAmountMinor")); return }
|
|
46
|
+
if (pricingKind !in setOf("graduated", "volume")) invalid(); keys(pricing, setOf("kind", "units", "tiers"))
|
|
47
|
+
val tiers = pricing.optJSONArray("tiers") ?: invalid(); if (tiers.length() !in 1..12) invalid()
|
|
48
|
+
var previous = BigDecimal.ZERO
|
|
49
|
+
for (index in 0 until tiers.length()) {
|
|
50
|
+
val tier = tiers.getJSONObject(index); keys(tier, setOf("upTo", "unitAmountMinor")); quantity(tier.opt("unitAmountMinor"))
|
|
51
|
+
if (index == tiers.length() - 1) { if (!tier.isNull("upTo")) invalid() }
|
|
52
|
+
else { val ceiling = quantity(tier.opt("upTo"), true); if (ceiling <= previous) invalid(); previous = ceiling }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.graphics.Color
|
|
5
|
+
import android.net.Uri
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import android.webkit.*
|
|
9
|
+
import kotlinx.coroutines.CoroutineScope
|
|
10
|
+
import kotlinx.coroutines.launch
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
import java.net.URI
|
|
13
|
+
import java.util.UUID
|
|
14
|
+
|
|
15
|
+
/** One local renderer/bridge implementation for Funnel and independent Paywall. */
|
|
16
|
+
internal class IndianaBundledWebView(
|
|
17
|
+
activity: Activity, label: String, private val work: CoroutineScope,
|
|
18
|
+
private val dispatch: suspend (String, Any?) -> Any?, private val onError: (Exception) -> Unit
|
|
19
|
+
) {
|
|
20
|
+
val view = WebView(activity)
|
|
21
|
+
private val shell = URI("https://indiana-sdk.invalid/")
|
|
22
|
+
private var port: WebMessagePort? = null
|
|
23
|
+
private var stopped = false
|
|
24
|
+
private val requests = mutableSetOf<UUID>()
|
|
25
|
+
init {
|
|
26
|
+
view.setBackgroundColor(Color.TRANSPARENT); view.contentDescription = label; view.isSaveEnabled = false
|
|
27
|
+
view.settings.apply {
|
|
28
|
+
javaScriptEnabled = true; domStorageEnabled = false; databaseEnabled = false
|
|
29
|
+
allowFileAccess = false; allowContentAccess = false; mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
|
30
|
+
cacheMode = WebSettings.LOAD_NO_CACHE; javaScriptCanOpenWindowsAutomatically = false; setSupportMultipleWindows(false)
|
|
31
|
+
mediaPlaybackRequiresUserGesture = true; saveFormData = false
|
|
32
|
+
}
|
|
33
|
+
view.webViewClient = object: WebViewClient() {
|
|
34
|
+
override fun shouldInterceptRequest(webView: WebView, request: WebResourceRequest): WebResourceResponse? {
|
|
35
|
+
if (request.url.host != "indiana-sdk.invalid") return null
|
|
36
|
+
return if (request.method == "GET" && request.url.toString() == shell.toString())
|
|
37
|
+
WebResourceResponse("text/html", "UTF-8", activity.assets.open("indiana/index.html"))
|
|
38
|
+
else WebResourceResponse("text/plain", "UTF-8", 404, "Not Found", emptyMap(), java.io.ByteArrayInputStream(byteArrayOf()))
|
|
39
|
+
}
|
|
40
|
+
override fun shouldOverrideUrlLoading(webView: WebView, request: WebResourceRequest): Boolean {
|
|
41
|
+
val url = try { URI(request.url.toString()) } catch (_: Exception) { return true }
|
|
42
|
+
return if (request.isForMainFrame) url != shell else !allowsFunnelRendererNavigation(url, shell)
|
|
43
|
+
}
|
|
44
|
+
override fun onPageFinished(webView: WebView, url: String) {
|
|
45
|
+
if (stopped || url != shell.toString()) return
|
|
46
|
+
port?.close()
|
|
47
|
+
val channels = view.createWebMessageChannel(); port = channels[0]
|
|
48
|
+
channels[0].setWebMessageCallback(object: WebMessagePort.WebMessageCallback() {
|
|
49
|
+
override fun onMessage(port: WebMessagePort, message: WebMessage) { receive(message.data ?: return) }
|
|
50
|
+
}, Handler(Looper.getMainLooper()))
|
|
51
|
+
val nonce = UUID.randomUUID().toString()
|
|
52
|
+
view.evaluateJavascript("window.__indianaFunnelPreparePort(" + JSONObject.quote(nonce) + ")") { accepted ->
|
|
53
|
+
if (!stopped && view.url == shell.toString() && accepted == "true")
|
|
54
|
+
view.postWebMessage(WebMessage(JSONObject().put("type", "indiana-native-port").put("nonce", nonce).toString(), arrayOf(channels[1])), Uri.parse(shell.toString()))
|
|
55
|
+
else { channels[0].close(); channels[1].close() }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
override fun onReceivedError(webView: WebView, request: WebResourceRequest, error: WebResourceError) {
|
|
59
|
+
if (!stopped && request.isForMainFrame) onError(IndianaClientException("renderer_unavailable"))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
fun start() { if (!stopped) view.loadUrl(shell.toString()) }
|
|
64
|
+
private fun receive(raw: String) {
|
|
65
|
+
if (stopped || raw.toByteArray().size > 262144) return
|
|
66
|
+
val message = try { JSONObject(raw) } catch (_: Exception) { return }
|
|
67
|
+
if (message.keys().asSequence().toSet() != setOf("id", "method", "payload")) return
|
|
68
|
+
val id = try { UUID.fromString(message.getString("id")) } catch (_: Exception) { return }
|
|
69
|
+
if (requests.size >= 8 || !requests.add(id)) return
|
|
70
|
+
work.launch {
|
|
71
|
+
try {
|
|
72
|
+
val result = dispatch(message.getString("method"), message.opt("payload"))
|
|
73
|
+
send(JSONObject().put("id", id.toString()).put("result", result ?: JSONObject.NULL))
|
|
74
|
+
} catch (error: Exception) {
|
|
75
|
+
if (!stopped) {
|
|
76
|
+
val detail = JSONObject().put("code", if (error is IndianaClientException) error.code else "NATIVE_OPERATION_FAILED")
|
|
77
|
+
if (error is IndianaClientException) error.code.removePrefix("request_failed_").toIntOrNull()?.let { detail.put("status", it) }
|
|
78
|
+
send(JSONObject().put("id", id.toString()).put("error", detail)); onError(error)
|
|
79
|
+
}
|
|
80
|
+
} finally { requests.remove(id) }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
fun send(value: JSONObject) { if (!stopped) port?.postMessage(WebMessage(value.toString())) }
|
|
84
|
+
fun event(name: String) { send(JSONObject().put("event", name)) }
|
|
85
|
+
fun destroy() {
|
|
86
|
+
if (stopped) return
|
|
87
|
+
event("invalidated"); stopped = true; port?.close(); port = null; requests.clear()
|
|
88
|
+
view.stopLoading(); view.destroy()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
import java.util.UUID
|
|
6
|
+
import kotlinx.coroutines.sync.Mutex
|
|
7
|
+
import kotlinx.coroutines.sync.withLock
|
|
8
|
+
|
|
9
|
+
/** Version-pinned independent Page, Popup or Banner. No Funnel is created. */
|
|
10
|
+
class IndianaContentSession internal constructor(private val configuration: IndianaConfiguration, input: JSONObject, private val locale: String, inputs: JSONObject, private val send: suspend (JSONObject) -> JSONObject) {
|
|
11
|
+
private val mutex = Mutex()
|
|
12
|
+
private val input = JSONObject(input.toString())
|
|
13
|
+
private val inputs = JSONObject(inputs.toString())
|
|
14
|
+
@Volatile private var stopped = false
|
|
15
|
+
@Volatile private var value: JSONObject? = null
|
|
16
|
+
@Volatile private var invalidation: (() -> Unit)? = null
|
|
17
|
+
val current: JSONObject? get() = value?.let { JSONObject(it.toString()) }
|
|
18
|
+
init { require(locale.isNotEmpty() && locale.length <= 35 && inputs.length() <= 40) }
|
|
19
|
+
fun close() { if (stopped) return; stopped = true; value = null; invalidation?.invoke(); invalidation = null }
|
|
20
|
+
suspend fun refresh(): JSONObject = mutex.withLock {
|
|
21
|
+
if (stopped) throw IndianaClientException("identity_changed")
|
|
22
|
+
val content = send(JSONObject(input.toString())).getJSONObject("content")
|
|
23
|
+
if (stopped || content.getString("projectId") != configuration.scope.projectId.toString() || content.getString("kind") != input.getString("kind") || (value != null && value!!.getString("id") != content.getString("id"))) throw IndianaClientException("invalid_response")
|
|
24
|
+
input.put("selector", JSONObject().put("type", "version").put("id", content.getString("id")))
|
|
25
|
+
value = JSONObject(content.toString()); JSONObject(content.toString())
|
|
26
|
+
}
|
|
27
|
+
internal fun onInvalidation(callback: () -> Unit) { invalidation = callback; if (stopped) callback() }
|
|
28
|
+
internal fun hostInit(): JSONObject = JSONObject().put("event", "init").put("surface", "content").put("scope", JSONObject().put("projectId", configuration.scope.projectId.toString()).put("environment", configuration.scope.environment.value)).put("apiOrigin", configuration.apiOrigin)
|
|
29
|
+
internal suspend fun hostContent(): JSONObject {
|
|
30
|
+
if (stopped) throw IndianaClientException("identity_changed")
|
|
31
|
+
return JSONObject().put("content", current ?: refresh()).put("locale", locale).put("inputs", JSONObject(inputs.toString()))
|
|
32
|
+
}
|
|
33
|
+
internal fun trustedAction(raw: JSONObject): JSONObject = safe {
|
|
34
|
+
if (stopped || raw.keys().asSequence().toSet() != setOf("requestId", "contentVersionId", "actionId", "event", "value", "answers", "selection", "requestedClose")) throw IndianaClientException("invalid_command")
|
|
35
|
+
UUID.fromString(raw.getString("requestId"))
|
|
36
|
+
val content = value ?: throw IndianaClientException("invalid_command")
|
|
37
|
+
val document = content.getJSONObject("document")
|
|
38
|
+
val id = raw.getString("actionId")
|
|
39
|
+
val actions = document.getJSONObject("screen").getJSONArray("actions")
|
|
40
|
+
val action = (0 until actions.length()).map { actions.getJSONObject(it) }.firstOrNull { it.getString("id") == id } ?: throw IndianaClientException("invalid_command")
|
|
41
|
+
val properties = action.getJSONObject("properties")
|
|
42
|
+
if (raw.getString("contentVersionId") != content.getString("id") || !document.getJSONObject("contract").getJSONObject("outputs").has(id) || action.getString("type") != "event" || raw.getString("event") != action.getString("event") || raw.getBoolean("requestedClose") != action.getBoolean("close") || !IndianaSchema.equal(raw.opt("value"), if (properties.has("value")) properties.opt("value") else properties) || raw.optJSONObject("answers") == null || raw.optJSONObject("selection") == null) throw IndianaClientException("invalid_command")
|
|
43
|
+
JSONObject(raw.toString())
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.widget.FrameLayout
|
|
5
|
+
import kotlinx.coroutines.CoroutineScope
|
|
6
|
+
import kotlinx.coroutines.Dispatchers
|
|
7
|
+
import kotlinx.coroutines.SupervisorJob
|
|
8
|
+
import kotlinx.coroutines.cancel
|
|
9
|
+
import org.json.JSONObject
|
|
10
|
+
|
|
11
|
+
/** Independent content; actions are explicit callbacks into the merchant host. */
|
|
12
|
+
class IndianaContentView(activity: Activity, val session: IndianaContentSession): FrameLayout(activity) {
|
|
13
|
+
var onAction: (suspend (JSONObject) -> Unit)? = null
|
|
14
|
+
var onDismiss: (() -> Unit)? = null
|
|
15
|
+
var onError: ((Exception) -> Unit)? = null
|
|
16
|
+
private val work = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
17
|
+
private val host = IndianaBundledWebView(activity, "Indiana content", work, ::dispatch) { error -> onError?.invoke(error) }
|
|
18
|
+
private var stopped = false
|
|
19
|
+
init {
|
|
20
|
+
addView(host.view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
21
|
+
session.onInvalidation { post { destroy() } }; host.start()
|
|
22
|
+
}
|
|
23
|
+
private suspend fun dispatch(method: String, raw: Any?): Any? = when (method) {
|
|
24
|
+
"ready" -> { host.send(session.hostInit()); null }
|
|
25
|
+
"content" -> session.hostContent()
|
|
26
|
+
"contentAction" -> {
|
|
27
|
+
val trusted = session.trustedAction(raw as? JSONObject ?: throw IndianaClientException("invalid_command"))
|
|
28
|
+
(onAction ?: throw IndianaClientException("unsupported_capability"))(trusted); null
|
|
29
|
+
}
|
|
30
|
+
"dismiss" -> { close(); null }
|
|
31
|
+
"error" -> { onError?.invoke(IndianaClientException("renderer_failed")); null }
|
|
32
|
+
else -> throw IndianaClientException("invalid_command")
|
|
33
|
+
}
|
|
34
|
+
fun destroy() { if (stopped) return; stopped = true; host.destroy(); work.cancel(); removeView(host.view); session.close(); onDismiss?.invoke() }
|
|
35
|
+
fun close() = destroy()
|
|
36
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Generated by scripts/sdk/generate-native-protocol.mjs. Do not edit.
|
|
2
|
+
package ai.tinkerland.indiana
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
|
|
5
|
+
enum class IndianaCustomerOperation(val wireName: String, internal val path: String, internal val method: String) {
|
|
6
|
+
bindPushInstallation("bindPushInstallation", "push/installations/bind", "POST"),
|
|
7
|
+
readPushInstallation("readPushInstallation", "push/installations/read", "POST"),
|
|
8
|
+
updatePushAddress("updatePushAddress", "push/installations/address", "POST"),
|
|
9
|
+
readMessagingPreference("readMessagingPreference", "messaging/preferences/read", "POST"),
|
|
10
|
+
setMessagingPreference("setMessagingPreference", "messaging/preferences/set", "POST"),
|
|
11
|
+
getFeatures("getFeatures", "features", "GET"),
|
|
12
|
+
evaluateFeatures("evaluateFeatures", "features/evaluate", "POST"),
|
|
13
|
+
trackEvents("trackEvents", "events", "POST"),
|
|
14
|
+
restoreNativePurchase("restoreNativePurchase", "subscriptions/native/restore", "POST"),
|
|
15
|
+
setupPayment("setupPayment", "billing/setup-payment", "POST"),
|
|
16
|
+
readPaymentSetup("readPaymentSetup", "billing/payment-setup", "POST"),
|
|
17
|
+
previewAttach("previewAttach", "subscriptions/preview-attach", "POST"),
|
|
18
|
+
attach("attach", "subscriptions/attach", "POST"),
|
|
19
|
+
readSubscriptionOperation("readSubscriptionOperation", "subscriptions/operations/read", "POST"),
|
|
20
|
+
previewUpdate("previewUpdate", "subscriptions/preview-update", "POST"),
|
|
21
|
+
update("update", "subscriptions/update", "POST"),
|
|
22
|
+
readSubscriptionUpdate("readSubscriptionUpdate", "subscriptions/updates/read", "POST"),
|
|
23
|
+
readBillingControls("readBillingControls", "billing/controls/read", "POST"),
|
|
24
|
+
prepareBillingAuthorization("prepareBillingAuthorization", "billing/authorizations/prepare", "POST"),
|
|
25
|
+
confirmBillingAuthorization("confirmBillingAuthorization", "billing/authorizations/confirm", "POST"),
|
|
26
|
+
setBillingCoupons("setBillingCoupons", "billing/coupons", "POST"),
|
|
27
|
+
revokeBillingAuthorization("revokeBillingAuthorization", "billing/authorizations/revoke", "POST"),
|
|
28
|
+
listBillingInvoices("listBillingInvoices", "billing/invoices/read", "POST"),
|
|
29
|
+
confirmBillingAdjustment("confirmBillingAdjustment", "billing/invoices/confirm-adjustment", "POST"),
|
|
30
|
+
checkEntitlement("checkEntitlement", "entitlements/check", "POST"),
|
|
31
|
+
resolveContent("resolveContent", "content/resolve", "POST"),
|
|
32
|
+
resolvePaywall("resolvePaywall", "paywalls/resolve", "POST"),
|
|
33
|
+
resolveOffering("resolveOffering", "offerings/resolve", "POST"),
|
|
34
|
+
readHoldings("readHoldings", "customer-holdings", "POST"),
|
|
35
|
+
readLedger("readLedger", "customer-ledger", "POST"),
|
|
36
|
+
readBillingInvoice("readBillingInvoice", "billing/invoices/detail", "POST"),
|
|
37
|
+
readCreditStatement("readCreditStatement", "credit-statements/read", "POST"),
|
|
38
|
+
quoteBalance("quoteBalance", "balances/quote", "POST"),
|
|
39
|
+
redeemReward("redeemReward", "rewards/redeem", "POST"),
|
|
40
|
+
createReferralCode("createReferralCode", "rewards/referral-code", "POST"),
|
|
41
|
+
claimRewardProduct("claimRewardProduct", "rewards/product/claim", "POST"),
|
|
42
|
+
redeemCoupon("redeemCoupon", "offers/redeem", "POST"),
|
|
43
|
+
listOffers("listOffers", "offers", "GET"),
|
|
44
|
+
grantOffer("grantOffer", "offers/grant", "POST");
|
|
45
|
+
}
|
|
46
|
+
suspend fun IndianaClient.bindPushInstallation(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.bindPushInstallation, input)
|
|
47
|
+
suspend fun IndianaClient.readPushInstallation(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readPushInstallation, input)
|
|
48
|
+
suspend fun IndianaClient.updatePushAddress(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.updatePushAddress, input)
|
|
49
|
+
suspend fun IndianaClient.readMessagingPreference(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readMessagingPreference, input)
|
|
50
|
+
suspend fun IndianaClient.setMessagingPreference(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.setMessagingPreference, input)
|
|
51
|
+
suspend fun IndianaClient.getFeatures(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.getFeatures, input)
|
|
52
|
+
suspend fun IndianaClient.evaluateFeatures(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.evaluateFeatures, input)
|
|
53
|
+
suspend fun IndianaClient.trackEvents(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.trackEvents, input)
|
|
54
|
+
suspend fun IndianaClient.restoreNativePurchase(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.restoreNativePurchase, input)
|
|
55
|
+
suspend fun IndianaClient.setupPayment(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.setupPayment, input)
|
|
56
|
+
suspend fun IndianaClient.readPaymentSetup(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readPaymentSetup, input)
|
|
57
|
+
suspend fun IndianaClient.previewAttach(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.previewAttach, input)
|
|
58
|
+
suspend fun IndianaClient.attach(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.attach, input)
|
|
59
|
+
suspend fun IndianaClient.readSubscriptionOperation(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readSubscriptionOperation, input)
|
|
60
|
+
suspend fun IndianaClient.previewUpdate(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.previewUpdate, input)
|
|
61
|
+
suspend fun IndianaClient.update(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.update, input)
|
|
62
|
+
suspend fun IndianaClient.readSubscriptionUpdate(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readSubscriptionUpdate, input)
|
|
63
|
+
suspend fun IndianaClient.readBillingControls(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readBillingControls, input)
|
|
64
|
+
suspend fun IndianaClient.prepareBillingAuthorization(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.prepareBillingAuthorization, input)
|
|
65
|
+
suspend fun IndianaClient.confirmBillingAuthorization(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.confirmBillingAuthorization, input)
|
|
66
|
+
suspend fun IndianaClient.setBillingCoupons(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.setBillingCoupons, input)
|
|
67
|
+
suspend fun IndianaClient.revokeBillingAuthorization(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.revokeBillingAuthorization, input)
|
|
68
|
+
suspend fun IndianaClient.listBillingInvoices(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.listBillingInvoices, input)
|
|
69
|
+
suspend fun IndianaClient.confirmBillingAdjustment(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.confirmBillingAdjustment, input)
|
|
70
|
+
suspend fun IndianaClient.checkEntitlement(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.checkEntitlement, input)
|
|
71
|
+
suspend fun IndianaClient.resolveContent(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.resolveContent, input)
|
|
72
|
+
suspend fun IndianaClient.resolvePaywall(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.resolvePaywall, input)
|
|
73
|
+
suspend fun IndianaClient.resolveOffering(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.resolveOffering, input)
|
|
74
|
+
suspend fun IndianaClient.readHoldings(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readHoldings, input)
|
|
75
|
+
suspend fun IndianaClient.readLedgerRequest(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readLedger, input)
|
|
76
|
+
suspend fun IndianaClient.readBillingInvoice(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readBillingInvoice, input)
|
|
77
|
+
suspend fun IndianaClient.readCreditStatement(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readCreditStatement, input)
|
|
78
|
+
suspend fun IndianaClient.quoteBalance(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.quoteBalance, input)
|
|
79
|
+
suspend fun IndianaClient.redeemReward(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.redeemReward, input)
|
|
80
|
+
suspend fun IndianaClient.createReferralCode(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.createReferralCode, input)
|
|
81
|
+
suspend fun IndianaClient.claimRewardProduct(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.claimRewardProduct, input)
|
|
82
|
+
suspend fun IndianaClient.redeemCoupon(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.redeemCoupon, input)
|
|
83
|
+
suspend fun IndianaClient.listOffers(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.listOffers, input)
|
|
84
|
+
suspend fun IndianaClient.grantOffer(input: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.grantOffer, input)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
import org.json.JSONArray
|
|
5
|
+
import java.math.BigDecimal
|
|
6
|
+
import java.net.URI
|
|
7
|
+
|
|
8
|
+
/** Dispatch is based on generated schema fields, never arbitrary merchant JSON shapes. */
|
|
9
|
+
internal object IndianaCustomerResponseSemantics {
|
|
10
|
+
private fun require(value: Boolean) { if (!value) throw IndianaClientException("invalid_response") }
|
|
11
|
+
private fun o(value: Any?) = value as? JSONObject ?: JSONObject()
|
|
12
|
+
private fun a(value: Any?) = (value as? JSONArray)?.let { array -> (0 until array.length()).map { o(array.opt(it)) } } ?: emptyList()
|
|
13
|
+
private fun s(value: Any?) = value as? String ?: ""
|
|
14
|
+
private fun n(value: Any?) = when (value) { is String -> value.toBigDecimalOrNull() ?: BigDecimal.ZERO; is Number -> value.toString().toBigDecimal(); else -> BigDecimal.ZERO }
|
|
15
|
+
private fun nil(value: Any?) = value == null || value == JSONObject.NULL
|
|
16
|
+
private fun unique(values: List<String>) = values.distinct().size == values.size
|
|
17
|
+
private fun time(value: Any?) = utcMillis(s(value))
|
|
18
|
+
private fun strings(value: Any?) = (value as? JSONArray)?.let { array -> (0 until array.length()).map { array.getString(it) } } ?: emptyList()
|
|
19
|
+
fun rules(original: Any?, schema: JSONObject): Any? {
|
|
20
|
+
val rules = schema.optJSONArray("x-indiana-rules")?.let { strings(it).toSet() } ?: emptySet()
|
|
21
|
+
val value = if ("trim" in rules && original is String) original.trim { it in "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" } else original
|
|
22
|
+
if ("positive" in rules) require(n(value) > BigDecimal.ZERO); if ("atLeastOne" in rules) require(n(value) >= BigDecimal.ONE)
|
|
23
|
+
if ("lowercase" in rules) require(s(value) == s(value).lowercase())
|
|
24
|
+
if ("https" in rules) { val url = URI(s(value)); require(url.scheme == "https" && !url.host.isNullOrEmpty()) }
|
|
25
|
+
if ("locale" in rules) { val parts = s(value).split('-'); require(parts.first() !in IndianaProtocol.nonCanonicalLocaleLanguages && parts.drop(1).none { if (it.length == 4) it in IndianaProtocol.nonCanonicalLocaleScripts else it in IndianaProtocol.nonCanonicalLocaleRegions }) }
|
|
26
|
+
if ("notNegativeZero" in rules) require(!s(value).startsWith('-') || n(value).compareTo(BigDecimal.ZERO) != 0)
|
|
27
|
+
if ("boundedAdjustment" in rules) require(s(value) != "-0" && n(value) >= BigDecimal(-200000000) && n(value) <= BigDecimal(200000000))
|
|
28
|
+
if ("orderedPair" in rules && value is JSONArray && value.length() == 2) require(n(value.opt(0)) <= n(value.opt(1)))
|
|
29
|
+
if (rules.any { it in listOf("jsonBudget", "attributes", "condition") }) { jsonBudget(value); if (rules.any { it in listOf("attributes", "condition") }) require(o(value).length() <= 128) }
|
|
30
|
+
if ("condition" in rules) condition(o(value))
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
private fun jsonBudget(value: Any?) {
|
|
34
|
+
var count = 0
|
|
35
|
+
fun visit(value: Any?, depth: Int) {
|
|
36
|
+
require(++count <= 10000 && depth <= 20)
|
|
37
|
+
fun text(text: String) { require('\u0000' !in text && text.indices.none { Character.isHighSurrogate(text[it]) && (it + 1 >= text.length || !Character.isLowSurrogate(text[it + 1])) || Character.isLowSurrogate(text[it]) && (it == 0 || !Character.isHighSurrogate(text[it - 1])) }) }
|
|
38
|
+
when (value) { is String -> text(value); is JSONArray -> for (index in 0 until value.length()) visit(value.opt(index), depth + 1); is JSONObject -> for (key in value.keys()) { text(key); require(key !in listOf("__proto__", "constructor", "prototype")); visit(value.opt(key), depth + 1) } }
|
|
39
|
+
}
|
|
40
|
+
visit(value, 0)
|
|
41
|
+
val encoded = if (value is JSONObject || value is JSONArray) value.toString() else JSONArray().put(value).toString().drop(1).dropLast(1)
|
|
42
|
+
require(encoded.toByteArray(Charsets.UTF_8).size <= 65536)
|
|
43
|
+
}
|
|
44
|
+
private fun condition(value: JSONObject) {
|
|
45
|
+
val operators = setOf("\$in", "\$ini", "\$inGroup", "\$nin", "\$nini", "\$notInGroup", "\$gt", "\$gte", "\$lt", "\$lte", "\$regex", "\$regexi", "\$ne", "\$eq", "\$size", "\$elemMatch", "\$all", "\$alli", "\$not", "\$type", "\$exists", "\$vgt", "\$vgte", "\$vlt", "\$vlte", "\$vne", "\$veq", "\$and", "\$or", "\$nor", "\$savedGroup")
|
|
46
|
+
require(value.optString("source") !in listOf("all", "any", "group", "parameter", "answer", "attribute", "result") || !value.has("operator") && !value.has("predicates") && !value.has("groupId"))
|
|
47
|
+
fun visit(current: Any?) {
|
|
48
|
+
if (current is JSONArray) for (index in 0 until current.length()) visit(current.opt(index))
|
|
49
|
+
if (current is JSONObject) for (key in current.keys()) {
|
|
50
|
+
val child = current.opt(key)
|
|
51
|
+
if (key == "\$savedGroup") { val group = o(child); require(group.keys().asSequence().all { it in listOf("id", "attributeKey") } && group.optString("id").matches(IndianaProtocol.uuidPattern) && (!group.has("attributeKey") || group.getString("attributeKey").length in 1..128)) }
|
|
52
|
+
if (key.startsWith("$")) require(key in operators) else require(key.split('.').none { it in listOf("__proto__", "constructor", "prototype") })
|
|
53
|
+
if (key in listOf("\$regex", "\$regexi") && child is String) {
|
|
54
|
+
require(!Regex("\\(\\?[<!=]|\\\\[0-9]|\\\\k<").containsMatchIn(child))
|
|
55
|
+
for (match in Regex("\\{([0-9]+)(?:,([0-9]*))?\\}").findAll(child)) for (index in listOf(1, 2)) require((match.groups[index]?.value?.toIntOrNull() ?: 0) <= 255)
|
|
56
|
+
if (key == "\$regexi") require(child.all { it.code <= 127 })
|
|
57
|
+
val syntax = buildString { var index = 0; while (index < child.length) { if (child[index] == '\\' && index + 1 < child.length) { val next = child[index + 1]; if (next.isLetter() && next.code < 128 && next !in "bBcdDsSwWfnrtvux") append(next) else { append('\\'); append(next) }; index += 2 } else { append(child[index]); index++ } } }
|
|
58
|
+
Regex(syntax)
|
|
59
|
+
}; visit(child)
|
|
60
|
+
}
|
|
61
|
+
}; visit(value)
|
|
62
|
+
}
|
|
63
|
+
fun objectValue(v: JSONObject, fields: Set<String>, path: List<String>) {
|
|
64
|
+
fun has(vararg keys: String) = fields.containsAll(keys.toList())
|
|
65
|
+
if (has("productKind", "seatBundle", "applePromotionalOffer", "replacement", "offerKind")) {
|
|
66
|
+
val apple = v.getString("provider") == "apple"; val member = v.getString("productKind") == "membership"; val kind = v.getString("offerKind"); val replacement = o(v.opt("replacement")); val proof = o(v.opt("applePromotionalOffer"))
|
|
67
|
+
if (replacement.length() > 0) require(member && kind == "none" && v.isNull("trialDays") && replacement.getString("policy") == if (apple) "apple_store" else "google_full_price")
|
|
68
|
+
if (!member) require(v.isNull("trialDays") && kind == "none" && !v.has("basePlanId")) else if (!apple) require(v.has("basePlanId"))
|
|
69
|
+
require(n(v.opt("discountMinor")) <= n(v.opt("catalogAmountMinor")) && unique(a(v.opt("seatBundle")).map { it.getString("key") }))
|
|
70
|
+
require(v.isNull("trialDays") || n(v.opt("discountMinor")).compareTo(BigDecimal.ZERO) == 0 && kind != "none")
|
|
71
|
+
require(kind != "none" || n(v.opt("discountMinor")).compareTo(BigDecimal.ZERO) == 0)
|
|
72
|
+
if (apple) {
|
|
73
|
+
require(!v.isNull("appAccountToken") && !v.has("obfuscatedAccountId") && !v.has("obfuscatedProfileId") && !v.has("basePlanId"))
|
|
74
|
+
require((kind == "promotional") == !v.isNull("applePromotionalOffer") && (kind == "promotional") == v.has("offerId"))
|
|
75
|
+
require(proof.length() == 0 || proof.getString("offerId") == v.optString("offerId"))
|
|
76
|
+
} else require(!v.has("appAccountToken") && v.optString("obfuscatedAccountId").isNotEmpty() && v.optString("obfuscatedProfileId").isNotEmpty() && v.isNull("applePromotionalOffer") && (kind == "none") == !v.has("offerId"))
|
|
77
|
+
}
|
|
78
|
+
if (has("kind", "tiers") && v.optString("kind") in listOf("graduated", "volume")) {
|
|
79
|
+
val rows = a(v.opt("tiers")); require(rows.isNotEmpty() && rows.last().isNull("upTo"))
|
|
80
|
+
for ((index, row) in rows.dropLast(1).withIndex()) require(!row.isNull("upTo") && (index == 0 || n(row.opt("upTo")) > n(rows[index - 1].opt("upTo"))))
|
|
81
|
+
require(rows.none { n(it.opt("flatAmountMinor")) > BigDecimal.ZERO } || v.getString("kind") == "volume" && rows.size > 1)
|
|
82
|
+
}
|
|
83
|
+
if (has("units", "prices", "minimumQuantity", "maximumQuantity")) {
|
|
84
|
+
require(n(v.opt("units")) > BigDecimal.ZERO && n(v.opt("maximumQuantity")) >= n(v.opt("minimumQuantity")))
|
|
85
|
+
val prices = a(v.opt("prices")); require(unique(prices.map { it.getString("currency") }))
|
|
86
|
+
for (price in prices) { require(!price.has("pricing") || n(price.opt("amountMinor")).compareTo(BigDecimal.ZERO) == 0); val bounds = listOf(n(v.opt("maximumQuantity"))) + a(o(price.opt("pricing")).opt("tiers")).filter { !it.isNull("upTo") }.flatMap { listOf(n(it.opt("upTo")), n(it.opt("upTo")) + BigDecimal.ONE) }; for (quantity in bounds) if (quantity <= n(v.opt("maximumQuantity"))) prepaid(price, quantity) }
|
|
87
|
+
}
|
|
88
|
+
if (has("terms", "includedAmount", "priceId", "productId", "unitAmountMinor")) {
|
|
89
|
+
val terms = v.getJSONObject("terms"); val quantity = n(v.opt("quantity"))
|
|
90
|
+
require(quantity >= n(terms.opt("minimumQuantity")) && quantity <= n(terms.opt("maximumQuantity")) && v.isNull("productId") == v.isNull("priceId"))
|
|
91
|
+
require(quantity.compareTo(BigDecimal.ZERO) == 0 || !v.has("pricing") && n(v.opt("unitAmountMinor")).compareTo(BigDecimal.ZERO) == 0 || !v.isNull("priceId"))
|
|
92
|
+
require(n(v.opt("includedAmount")) + n(terms.opt("units")) * quantity <= BigDecimal("999999999999.999999")); prepaid(v, quantity)
|
|
93
|
+
}
|
|
94
|
+
if (has("lineId", "baseAmountMinor", "catalogAmountMinor", "seats", "trial")) {
|
|
95
|
+
val seats = a(v.opt("seats")); require(unique(seats.map { it.getString("key") }))
|
|
96
|
+
require(seats.all { n(it.opt("quantity")) >= n(it.opt("includedQuantity")) && n(it.opt("quantity")) <= n(it.opt("maximumQuantity")) })
|
|
97
|
+
var total = n(v.opt("baseAmountMinor")) + seats.fold(BigDecimal.ZERO) { total, seat -> total + n(seat.opt("unitAmountMinor")) * (n(seat.opt("quantity")) - n(seat.opt("includedQuantity"))) }
|
|
98
|
+
for (row in a(v.opt("prepaid"))) total += prepaid(row, n(row.opt("quantity"))); require(total.compareTo(n(v.opt("catalogAmountMinor"))) == 0)
|
|
99
|
+
}
|
|
100
|
+
if (has("percent_off", "amount_off", "currency", "duration_in_months", "duration")) require(v.has("percent_off") != v.has("amount_off") && !v.has("amount_off") == !v.has("currency") && (v.getString("duration") == "repeating") == v.has("duration_in_months"))
|
|
101
|
+
if (has("operationId", "lines", "discount", "charge", "nativeOffer", "execution")) attachQuote(v)
|
|
102
|
+
if (has("executor", "checkoutUrl", "execution")) {
|
|
103
|
+
require(v.isNull("execution") || v.getString("executor") == "merchant")
|
|
104
|
+
if (has("quoteFinalized", "orderId")) require(v.isNull("checkoutUrl") || v.getString("executor") == "indiana")
|
|
105
|
+
if (!v.isNull("checkoutUrl")) require(URI(v.getString("checkoutUrl")).scheme == "https")
|
|
106
|
+
}
|
|
107
|
+
if (has("action", "executor", "update", "cancel", "schedule", "release")) {
|
|
108
|
+
for (action in listOf("update", "cancel", "schedule", "release")) require(!v.isNull(action) == (v.getString("action") == action))
|
|
109
|
+
require(v.getString("executor") == "indiana" || (a(o(v.opt("update")).opt("items")) + a(o(v.opt("schedule")).opt("nextItems"))).all { !it.isNull("price") })
|
|
110
|
+
}
|
|
111
|
+
if (has("offerKind", "offerId", "discountMinor", "trialDays") && fields.size == 4) require((v.getString("offerKind") != "none" || v.isNull("offerId") && n(v.opt("discountMinor")).compareTo(BigDecimal.ZERO) == 0 && v.isNull("trialDays")) && (v.isNull("trialDays") || n(v.opt("discountMinor")).compareTo(BigDecimal.ZERO) == 0))
|
|
112
|
+
if (has("lookupKey", "displayName", "items", "metadata")) offering(v)
|
|
113
|
+
if (has("tiers", "assignments")) { val tiers = a(v.opt("tiers")).map { it.getString("id") }; val assignments = a(v.opt("assignments")); require(unique(tiers) && unique(assignments.map { it.getString("productVersionId") }) && unique(assignments.map { it.getString("tierId") + ":" + it.getString("period") }) && assignments.all { it.getString("tierId") in tiers }) }
|
|
114
|
+
if (has("kind", "screen", "contract", "name")) { val screen = v.getJSONObject("screen"); val kind = v.getString("kind"); val actions = a(screen.opt("actions")); val outputs = v.getJSONObject("contract").getJSONObject("outputs"); require(screen.getString("surface") == kind && screen.getString("name") == v.getString("name") && (kind == "banner") == (o(screen.opt("presentation")).optString("type") == "inline") && actions.all { it.getString("type") in listOf("event", "navigate") }); require(actions.filter { it.getString("type") == "event" }.map { it.getString("id") }.toSet() == outputs.keys().asSequence().toSet()); require(v.toString().toByteArray(Charsets.UTF_8).size <= 300000) }
|
|
115
|
+
if (has("pages", "actions", "initialPageId", "strings", "surface")) screen(v)
|
|
116
|
+
if (has("type", "planIds", "label", "fallback") && v.optString("type") == "plan_picker") { val ids = strings(v.opt("planIds")); require(unique(ids)); if (v.has("grouping")) require(productGrouping(v.getJSONObject("grouping")) == ids.toSet()) }
|
|
117
|
+
if (has("type", "source", "expired", "granularity") && v.optString("type") == "countdown") require(v.getString("source") != "fixed" || v.has("at"))
|
|
118
|
+
if (has("minimumQuantity", "maximumQuantity", "defaultQuantity", "step")) { val quantity = v.getLong("defaultQuantity"); val minimum = v.getLong("minimumQuantity"); require(quantity >= minimum && quantity <= v.getLong("maximumQuantity") && (quantity - minimum) % v.getLong("step") == 0L) }
|
|
119
|
+
if (has("id", "event", "properties", "type", "close")) require(o(v.opt("properties")).length() <= 20)
|
|
120
|
+
if (has("transactionIds", "membership")) require(unique(strings(v.opt("transactionIds"))))
|
|
121
|
+
if (has("defaultLocale", "locales") && path.last() == "displayCopy") { val locales = v.getJSONObject("locales"); require(locales.length() in 1..20 && locales.has(v.getString("defaultLocale")) && v.toString().toByteArray(Charsets.UTF_8).size <= 50000) }
|
|
122
|
+
if (has("kind", "threshold", "quantity", "purchaseUnits")) require(n(v.opt("quantity")) >= BigDecimal.ONE && n(v.opt("purchaseUnits")) > BigDecimal.ZERO)
|
|
123
|
+
if (has("kind", "cashPrice", "currency")) require(o(v.opt("cashPrice")).optString("currency") == v.getString("currency"))
|
|
124
|
+
if (has("currency", "pricing", "billingCycle")) o(v.opt("pricing")).let { require(it.optString("kind") != "volume" || a(it.opt("tiers")).size == 1) }
|
|
125
|
+
if (has("status", "terms", "acceptedAt", "effectiveAt", "revokedAt")) { val status = v.getString("status"); require((status != "prepared" || v.isNull("acceptedAt")) && (status != "active" || !v.isNull("acceptedAt") && !v.isNull("effectiveAt")) && (status == "revoked") == !v.isNull("revokedAt")) }
|
|
126
|
+
if (has("basis", "thresholds", "limit")) for (field in listOf("thresholds", "includedThresholds", "absoluteThresholds")) v.optJSONArray(field)?.let { for (index in 1 until it.length()) require(n(it.opt(index)) > n(it.opt(index - 1))) }
|
|
127
|
+
if (has("basis", "used", "reserved") && v.optString("basis") == "usage") for (key in listOf("used", "reserved")) require(v.getString(key).matches(Regex("^(0|[1-9][0-9]{0,11})(\\.[0-9]{1,6})?$")))
|
|
128
|
+
if (has("amountMinor", "requestId", "reason") && v.opt("amountMinor") is String) require(v.getString("amountMinor") != "-0" && n(v.opt("amountMinor")) >= BigDecimal(-200000000) && n(v.opt("amountMinor")) <= BigDecimal(200000000))
|
|
129
|
+
if (has("inputs", "outputs") && path.last() == "contract") require(o(v.opt("inputs")).length() <= 40 && o(v.opt("outputs")).length() <= 40)
|
|
130
|
+
if (has("formatVersion", "kind", "sha256", "url") && "paywall" in path) require(v.getString("kind") == "paywall" && v.getInt("formatVersion") == 1)
|
|
131
|
+
if (has("changeKind", "seat", "feature", "effectiveAt")) require((v.getString("changeKind") == "seats") == !v.isNull("seat") && (v.getString("changeKind") == "quantity") == v.has("feature"))
|
|
132
|
+
if (has("usage", "capacity", "remaining", "withinLimit", "operationId")) {
|
|
133
|
+
require(v.isNull("capacity") == v.isNull("remaining")); require(if (v.isNull("capacity")) v.getBoolean("withinLimit") else n(v.opt("remaining")).compareTo(n(v.opt("capacity")) - n(v.opt("usage"))) == 0 && v.getBoolean("withinLimit") == (n(v.opt("remaining")) >= BigDecimal.ZERO))
|
|
134
|
+
require((v.getLong("revision") == 0L) == v.isNull("operationId") && v.isNull("operationId") == v.isNull("updatedAt"))
|
|
135
|
+
}
|
|
136
|
+
if (has("usageType", "accessible", "available", "held", "debt", "usage")) {
|
|
137
|
+
require((v.getString("kind") == "metered") == !v.isNull("usageType") && (v.getBoolean("accessible") || v.getString("available") == "0" && v.getString("held") == "0"))
|
|
138
|
+
val usage = o(v.opt("usage")); if (v.optString("usageType") == "non_consumable") require(usage.length() > 0 && usage.getString("internal_feature_id") == v.getString("internal_feature_id") && v.getBoolean("unlimited") == usage.isNull("capacity") && v.getString("available") == (if (usage.isNull("remaining") || usage.getString("remaining").startsWith('-')) "0" else usage.getString("remaining")) && v.getString("held") == "0" && v.getString("debt") == "0") else require(v.isNull("usage"))
|
|
139
|
+
}
|
|
140
|
+
if (has("customerId", "rows", "nextCursor") && "ledger" in path) { val rows = a(v.opt("rows")); require(unique(rows.map { it.getString("type") + it.getString("id") })); if (!v.isNull("nextCursor")) { val last = rows.lastOrNull() ?: JSONObject(); val cursor = v.getJSONObject("nextCursor"); require(last.optString("id") == cursor.getString("id") && last.optString("type") == cursor.getString("type") && last.optString("occurredAt") == cursor.getString("at")) } }
|
|
141
|
+
if (has("kind", "accepted", "charges", "reservationStatus", "quantity") && v.optString("type") == "usage") {
|
|
142
|
+
val zero = v.getString("kind") == "set_usage" || v.getString("kind") == "consume" && v.getString("quantity") == "0"; val accepted = v.getBoolean("accepted"); val charges = a(v.opt("charges"))
|
|
143
|
+
require(if (zero) accepted && v.isNull("reason") && charges.isEmpty() && v.isNull("reservationStatus") else if (accepted) v.isNull("reason") && charges.isNotEmpty() else !v.isNull("reason") && charges.isEmpty())
|
|
144
|
+
require(!v.isNull("originalId") == (v.getString("kind") in listOf("confirm", "refund", "release", "expire")) && v.isNull("expiresAt") == v.isNull("reservationStatus"))
|
|
145
|
+
}
|
|
146
|
+
if (has("purpose", "productVersionId", "productName", "invoiceId", "originalId") && v.optString("type") == "transaction") { val product = v.getString("purpose") == "product"; require(v.isNull("currency") == v.isNull("amountMinor") && product == !v.isNull("productVersionId") && product == !v.isNull("productName") && product == !v.isNull("productVersion") && product == v.isNull("invoiceId") && (v.getString("kind") == "refund") == !v.isNull("originalId") && (v.getString("kind") != "refund" || v.isNull("amountMinor") || n(v.opt("amountMinor")) > BigDecimal.ZERO)) }
|
|
147
|
+
if (has("net", "internal_feature_id") && v.optString("net").startsWith('-')) require(n(v.opt("net")).compareTo(BigDecimal.ZERO) != 0)
|
|
148
|
+
if (has("funding", "units")) { require(n(v.opt("units")) > BigDecimal.ZERO); if (v.getString("funding") == "prepaid") { val rows = a(v.opt("pricing")); require(rows.fold(BigDecimal.ZERO) { sum, row -> sum + n(row.opt("units")) }.compareTo(n(v.opt("units"))) == 0 && rows.fold(BigDecimal.ZERO) { sum, row -> sum + n(row.opt("amount")) }.compareTo(n(v.opt("amount"))) == 0 && rows.all { (n(it.opt("unitCost")) * n(it.opt("units"))).compareTo(n(it.opt("amount"))) == 0 }) } }
|
|
149
|
+
if (has("allowed", "allocations", "quantity", "internal_feature_id") && v.getBoolean("allowed")) { val rows = a(v.opt("allocations")); val delta = rows.fold(BigDecimal.ZERO) { sum, row -> sum + n(row.opt("units")) } - n(v.opt("quantity")); val tolerance = if (rows.any { it.getString("funding") == "balance" }) BigDecimal("0.0000000001") else BigDecimal.ZERO; require(delta >= -tolerance && delta <= tolerance) }
|
|
150
|
+
}
|
|
151
|
+
private fun prepaid(price: JSONObject, quantity: BigDecimal): BigDecimal {
|
|
152
|
+
if (quantity.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO
|
|
153
|
+
var total = BigDecimal.ZERO; val pricing = o(price.opt("pricing"))
|
|
154
|
+
if (pricing.length() == 0) total = n(if (price.has("amountMinor")) price.opt("amountMinor") else price.opt("unitAmountMinor")) * quantity
|
|
155
|
+
else { var lower = BigDecimal.ZERO; for (row in a(pricing.opt("tiers"))) { val upper = if (row.isNull("upTo")) BigDecimal(10000) else n(row.opt("upTo")); if (pricing.getString("kind") == "volume" && quantity <= upper) { total = quantity * n(row.opt("unitAmountMinor")) + n(row.opt("flatAmountMinor")); break }; if (pricing.getString("kind") != "volume" && quantity > lower) total += (quantity.min(upper) - lower) * n(row.opt("unitAmountMinor")); lower = upper } }
|
|
156
|
+
require(total <= BigDecimal(200000000)); return total
|
|
157
|
+
}
|
|
158
|
+
private fun attachQuote(v: JSONObject) {
|
|
159
|
+
val lines = a(v.opt("lines")); val trial = o(lines.firstOrNull()?.opt("trial")); val execution = o(v.opt("execution")); val discount = o(v.opt("discount")); val provider = v.getString("provider")
|
|
160
|
+
if (provider == "stripe") {
|
|
161
|
+
if (trial.has("cardRequired") && !trial.getBoolean("cardRequired") && !o(execution.opt("invoiceMode")).optBoolean("enabled")) require(execution.length() == 0 && discount.length() == 0)
|
|
162
|
+
else {
|
|
163
|
+
require(execution.length() > 0 && o(execution.opt("metadata")).optString("grooveCheckoutId") == v.getString("operationId") && time(execution.opt("expiresAt")) <= time(v.opt("expiresAt")))
|
|
164
|
+
require(execution.getString("mode") == if (lines.any { it.getString("interval") !in listOf("one_time", "free") }) "subscription" else "payment")
|
|
165
|
+
require(execution.isNull("trialPeriodDays") == (trial.length() == 0) && (trial.length() == 0 || n(execution.opt("trialPeriodDays")).compareTo(n(trial.opt("days"))) == 0))
|
|
166
|
+
require(execution.getString("executor") == "indiana" || a(execution.opt("lineItems")).all { !it.isNull("price") })
|
|
167
|
+
val discounts = a(execution.opt("discounts")); require(discounts.size == if (discount.length() == 0) 0 else 1)
|
|
168
|
+
for (row in discounts) { val terms = o(row.opt("terms")); val coupon = row.getString("coupon"); require(coupon == terms.getString("id") && discount.length() > 0); if (coupon != discount.getString("couponId")) require(o(discount.opt("discount")).getString("type") == "amount" && coupon == discount.getString("couponId") + "_credit_" + discount.getString("offerId") + "_" + terms.get("amount_off").toString() && n(terms.opt("amount_off")).compareTo(n(o(discount.opt("discount")).opt("amountOffCents"))) == 0 && terms.optString("currency").uppercase() == v.getString("currency") && terms.getString("duration") == "once") }
|
|
169
|
+
}
|
|
170
|
+
require(!v.has("nativeOffer"))
|
|
171
|
+
} else { val selection = o(v.opt("nativeOffer")); require(execution.length() == 0 && selection.length() > 0 && lines.size == 1 && n(selection.opt("discountMinor")) <= n(v.opt("catalogAmountMinor")) && selection.isNull("trialDays") == (trial.length() == 0) && (trial.length() == 0 || n(selection.opt("trialDays")).compareTo(n(trial.opt("days"))) == 0) && (discount.length() == 0 || n(selection.opt("discountMinor")).compareTo(n(discount.opt("amountMinor"))) == 0)) }
|
|
172
|
+
if (discount.length() > 0) { val allocations = a(discount.opt("allocations")); require(v.isNull("billingSubscriptionId") && (provider == "stripe" || o(discount.opt("duration")).getString("type") == "once") && n(discount.opt("amountMinor")) <= n(v.opt("catalogAmountMinor")) && allocations.size == lines.size); for ((row, line) in allocations.zip(lines)) require(row.getString("productVersionId") == line.getString("productVersionId") && n(row.opt("amountMinor")) <= n(line.opt("catalogAmountMinor"))); require(allocations.fold(BigDecimal.ZERO) { total, row -> total + n(row.opt("amountMinor")) }.compareTo(n(discount.opt("amountMinor"))) == 0) }
|
|
173
|
+
if (!v.isNull("charge")) { val charge = v.getJSONObject("charge"); require(!v.isNull("billingSubscriptionId") && n(charge.opt("totalMinor")).compareTo(n(charge.opt("debitMinor")) - n(charge.opt("discountMinor")) + n(charge.opt("taxMinor"))) == 0 && n(charge.opt("discountMinor")) <= n(charge.opt("debitMinor")) && time(charge.opt("periodEndsAt")) > time(charge.opt("effectiveAt"))) }
|
|
174
|
+
require(unique(lines.map { it.getString("lineId") }) && lines.fold(BigDecimal.ZERO) { total, line -> total + n(line.opt("catalogAmountMinor")) }.compareTo(n(v.opt("catalogAmountMinor"))) == 0)
|
|
175
|
+
}
|
|
176
|
+
private fun offering(v: JSONObject) {
|
|
177
|
+
val products = mutableSetOf<String>(); val groups = mutableSetOf<String>(); var count = 0
|
|
178
|
+
fun visit(items: List<JSONObject>) { for (item in items) { count++; if (item.getString("type") == "product") { val id = item.getString("productId"); require(id == id.lowercase() && products.add(id)) } else { require(groups.add(item.getString("key"))); item.put("name", item.getString("name").trim()); visit(a(item.opt("items"))) } } }
|
|
179
|
+
visit(a(v.opt("items"))); require(products.size <= 20 && count <= 60)
|
|
180
|
+
val metadata = v.getJSONObject("metadata").toString(); require(metadata.codePointCount(0, metadata.length) <= 4000)
|
|
181
|
+
for (field in listOf("displayName", "lookupKey")) v.put(field, v.getString(field).trim())
|
|
182
|
+
}
|
|
183
|
+
private fun productGrouping(group: JSONObject): Set<String> = a(group.opt("assignments")).map { it.getString("productVersionId") }.toSet()
|
|
184
|
+
private fun screen(v: JSONObject) {
|
|
185
|
+
val pages = a(v.opt("pages")); val actions = a(v.opt("actions")); val strings = v.getJSONObject("strings"); val locales = strings.getJSONObject("locales").keys().asSequence().toSet(); val pageIds = pages.map { it.getString("id") }; val actionIds = actions.map { it.getString("id") }
|
|
186
|
+
require((v.getString("surface") == "paywall" || pages.size == 1) && pages.size <= 10 && actions.size <= 40 && locales.size <= 20 && strings.getString("defaultLocale") in locales && unique(pageIds) && unique(actionIds) && "next" !in pageIds && "back" !in pageIds)
|
|
187
|
+
require(v.isNull("initialPageId") || v.getString("initialPageId") in pageIds)
|
|
188
|
+
for (action in actions) if (action.getString("type") == "navigate") require(v.getString("surface") == "paywall" && action.getString("pageId") in pageIds)
|
|
189
|
+
val ids = mutableSetOf<String>(); var count = 0
|
|
190
|
+
fun visit(node: JSONObject, depth: Int) { require(depth <= 8 && ++count <= 150 && ids.add(node.getString("id"))); if (node.getString("type") == "button") require(node.getString("action") in actionIds); if (node.getString("type") == "custom_html") require(locales.containsAll(o(node.opt("html")).keys().asSequence().toSet())); if (node.getString("type") == "image") require(locales.containsAll(o(node.opt("localizedSrc")).keys().asSequence().toSet())); for (child in a(node.opt("children"))) visit(child, depth + 1) }
|
|
191
|
+
for (page in pages) visit(page.getJSONObject("root"), 1)
|
|
192
|
+
}
|
|
193
|
+
}
|