@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.
Files changed (83) hide show
  1. package/IndianaReactNative.podspec +19 -0
  2. package/README.md +61 -4
  3. package/THIRD_PARTY_NOTICES.md +74 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
  7. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
  8. package/dist/index.cjs +17 -1
  9. package/dist/index.d.cts +57408 -604
  10. package/dist/index.d.ts +57408 -604
  11. package/dist/index.js +17 -1
  12. package/ios/IndianaBridge.m +11 -0
  13. package/ios/IndianaBridge.swift +275 -0
  14. package/native-sdk/android/assets/indiana/index.html +268 -0
  15. package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
  16. package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
  17. package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
  18. package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
  19. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
  20. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +193 -0
  21. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
  22. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
  23. package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
  24. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
  25. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
  26. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
  27. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
  28. package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
  29. package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
  30. package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
  31. package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
  32. package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
  33. package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
  34. package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
  35. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
  36. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
  37. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
  38. package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
  39. package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
  40. package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
  41. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
  42. package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
  43. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
  44. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
  45. package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
  46. package/native-sdk/android/resources/renderer-data-url.txt +1 -0
  47. package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
  48. package/native-sdk/ios/AppleStorefront.swift +218 -0
  49. package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
  50. package/native-sdk/ios/BundledWebView.swift +87 -0
  51. package/native-sdk/ios/ContentSession.swift +48 -0
  52. package/native-sdk/ios/ContentView.swift +51 -0
  53. package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
  54. package/native-sdk/ios/CustomerResponseSemantics.swift +232 -0
  55. package/native-sdk/ios/CustomerSemantics.swift +145 -0
  56. package/native-sdk/ios/CustomerSession.swift +129 -0
  57. package/native-sdk/ios/FeatureClient.swift +86 -0
  58. package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
  59. package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
  60. package/native-sdk/ios/FunnelSession.swift +232 -0
  61. package/native-sdk/ios/FunnelView.swift +125 -0
  62. package/native-sdk/ios/GeneratedProtocol.swift +16 -0
  63. package/native-sdk/ios/HTTPTransport.swift +74 -0
  64. package/native-sdk/ios/IndianaClient.swift +601 -0
  65. package/native-sdk/ios/NativePendingKeychain.swift +52 -0
  66. package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
  67. package/native-sdk/ios/NativeStoreOffer.swift +34 -0
  68. package/native-sdk/ios/PaywallContent.swift +75 -0
  69. package/native-sdk/ios/PaywallSession.swift +124 -0
  70. package/native-sdk/ios/PaywallView.swift +87 -0
  71. package/native-sdk/ios/Protocol.swift +49 -0
  72. package/native-sdk/ios/PushInstallation.swift +154 -0
  73. package/native-sdk/ios/RendererNavigation.swift +8 -0
  74. package/native-sdk/ios/Resources.swift +15 -0
  75. package/native-sdk/ios/SDKVersion.swift +4 -0
  76. package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
  77. package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
  78. package/native-sdk/ios/WebView/index.html +268 -0
  79. package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
  80. package/native-sdk/ios/WireSchema.swift +107 -0
  81. package/native-sdk/ios/funnel-wire-schemas.json +1 -0
  82. package/package.json +8 -3
  83. package/react-native.config.cjs +12 -0
@@ -0,0 +1,120 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+ import java.math.BigDecimal
6
+ import java.math.MathContext
7
+ import java.math.RoundingMode
8
+
9
+ /** Current Customer Zod refinements/transforms not representable in JSON Schema. */
10
+ internal object IndianaCustomerSemantics {
11
+ fun preflight(operation: String, value: JSONObject) {
12
+ if (operation == "evaluateFeatures") value.optJSONObject("attributes")?.let { if (it.length() > 128) throw IndianaClientException("invalid_request"); jsonBudget(it) }
13
+ if (operation == "trackEvents") value.optJSONArray("events")?.let { events -> for (n in 0 until events.length()) events.optJSONObject(n)?.optJSONObject("properties")?.let { properties -> for (key in properties.keys()) jsonBudget(properties.get(key)) } }
14
+ }
15
+ fun defaults(operation: String, input: JSONObject): JSONObject {
16
+ val value = JSONObject(input.toString())
17
+ if (operation in listOf("previewAttach", "previewUpdate")) value.optJSONObject("invoiceMode")?.let {
18
+ if (!it.has("enable_plan_immediately")) it.put("enable_plan_immediately", false)
19
+ if (!it.has("finalize")) it.put("finalize", true)
20
+ }
21
+ if (operation == "trackEvents") value.optJSONArray("events")?.let { events ->
22
+ for (n in 0 until events.length()) events.optJSONObject(n)?.let { if (!it.has("properties")) it.put("properties", JSONObject()) }
23
+ }
24
+ return value
25
+ }
26
+ fun request(operation: String, value: JSONObject): JSONObject {
27
+ fun require(allowed: Boolean) { if (!allowed) throw IndianaClientException("invalid_request") }
28
+ if (operation == "previewAttach") {
29
+ val plans = objects(value.getJSONArray("plans")); val provider = value.getString("provider")
30
+ require(plans.map { it.getString("productVersionId") }.distinct().size == plans.size)
31
+ require(plans.all { (it.optJSONObject("featureQuantities")?.length() ?: 0) <= 100 })
32
+ require(!value.has("offerId") || !value.has("billingSubscriptionId") && (provider == "stripe" || value.has("nativeOffer")))
33
+ require(!value.has("nativeOffer") || provider != "stripe" && plans.size == 1)
34
+ require(!value.has("newBillingSubscription") || provider == "stripe" && !value.has("billingSubscriptionId"))
35
+ require(value.optJSONObject("invoiceMode")?.optBoolean("enabled") != true || provider == "stripe" && !value.has("entityId"))
36
+ value.optJSONObject("nativeOffer")?.let { offer ->
37
+ require(offer.getString("offerKind") != "none" || offer.isNull("offerId") && offer.getInt("discountMinor") == 0 && offer.isNull("trialDays"))
38
+ require(offer.isNull("trialDays") || offer.getInt("discountMinor") == 0)
39
+ }
40
+ }
41
+ if (operation == "previewUpdate") {
42
+ val change = value.getJSONObject("change"); val type = change.getString("type"); val timing = value.getString("timing")
43
+ require(timing != "store" || type == "plan")
44
+ require(type != "billing_cycle" || timing == if (change.opt("billingCycleAnchor") == "now") "immediate" else "renewal")
45
+ value.optJSONArray("membershipIds")?.let { group -> val ids = strings(group); require(type in listOf("cancel", "uncancel") && value.getString("membershipId") in ids && ids.distinct().size == ids.size) }
46
+ require(!value.has("carryOverBalances") && !value.has("carryOverUsages") || type == "plan" && timing == "immediate")
47
+ require(!value.has("prorationBehavior") || type == "quantity")
48
+ for (field in listOf("carryOverBalances", "carryOverUsages")) value.optJSONObject(field)?.optJSONArray("featureIds")?.let { val ids = strings(it); require(ids.distinct().size == ids.size) }
49
+ }
50
+ if (operation == "setBillingCoupons") { val ids = strings(value.getJSONArray("grantIds")); require(ids.distinct().size == ids.size) }
51
+ if (operation == "prepareBillingAuthorization" && value.getString("kind") == "auto_topup") {
52
+ require(decimal(value, "quantity") >= BigDecimal.ONE); normalize(value, "quantity"); normalize(value, "threshold")
53
+ }
54
+ if (operation in listOf("checkEntitlement", "resolvePaywall", "resolveOffering")) value.optJSONObject("requirement")?.let {
55
+ require(decimal(it, "quantity") > BigDecimal.ZERO); normalize(it, "quantity"); properties(it.getJSONObject("properties"))
56
+ }
57
+ if (operation == "quoteBalance") value.getJSONObject("command").let {
58
+ require(decimal(it, "quantity") > BigDecimal.ZERO); normalize(it, "quantity"); properties(it.getJSONObject("properties"))
59
+ }
60
+ if (operation == "readLedger") value.getJSONObject("query").let { if (!it.isNull("from") && !it.isNull("to")) require(it.getString("from") <= it.getString("to")) }
61
+ if (operation == "readCreditStatement") {
62
+ val elapsed = utcMillis(value.getString("to")) - utcMillis(value.getString("from"))
63
+ require(elapsed > 0 && elapsed <= 366L * 86400000L); require(value.isNull("after") || !value.isNull("snapshotAt"))
64
+ }
65
+ return value
66
+ }
67
+ fun response(operation: String, value: JSONObject) {
68
+ fun require(allowed: Boolean) { if (!allowed) throw IndianaClientException("invalid_response") }
69
+ val checks = listOfNotNull(
70
+ value.optJSONObject("check"),
71
+ if (operation == "resolvePaywall") value.optJSONObject("paywall")?.optJSONObject("check") else null,
72
+ )
73
+ for (check in checks) {
74
+ require(check.getBoolean("allowed") == check.isNull("reason")); require(!check.getBoolean("allowed") || check.isNull("preview"))
75
+ val candidates = check.optJSONObject("preview")?.optJSONArray("candidates")?.let(::objects) ?: emptyList()
76
+ require(candidates.map { it.getString("productVersionId") }.distinct().size == candidates.size)
77
+ require(candidates.all { (it.getString("action") == "upgrade") == !it.isNull("membershipId") })
78
+ }
79
+ if (operation == "quoteBalance") value.getJSONObject("quote").let { quote ->
80
+ require(if (quote.getBoolean("allowed")) quote.isNull("reason") else !quote.isNull("reason") && quote.getJSONArray("allocations").length() == 0)
81
+ if (!quote.has("overageBehavior")) require(!quote.has("requestedQuantity") && decimal(quote, "quantity") > BigDecimal.ZERO)
82
+ else require(quote.has("requestedQuantity") && decimal(quote, "requestedQuantity") >= decimal(quote, "quantity") && (quote.getString("overageBehavior") == "cap" || quote.getString("requestedQuantity") == quote.getString("quantity")))
83
+ }
84
+ }
85
+ private fun objects(value: JSONArray) = (0 until value.length()).map { value.getJSONObject(it) }
86
+ private fun strings(value: JSONArray) = (0 until value.length()).map { value.getString(it) }
87
+ private fun decimal(value: JSONObject, key: String) = value.getString(key).toBigDecimal()
88
+ private fun normalize(value: JSONObject, key: String) { value.put(key, value.getString(key).toBigDecimal().stripTrailingZeros().toPlainString()) }
89
+ private fun properties(value: JSONObject) {
90
+ if (value.length() > 16) throw IndianaClientException("invalid_request")
91
+ for (key in value.keys()) {
92
+ val item = value.get(key)
93
+ value.put(key, if (item is Number) {
94
+ val number = item.toDouble(); if (number == 0.0) "0" else {
95
+ // ECMAScript selects the closest shortest decimal that round-trips.
96
+ // JDK 17's Double.toString alone differs for e.g. 5e-324 and 1e18+128.
97
+ val exact = BigDecimal(number)
98
+ val decimal = (1..17).asSequence().map { exact.round(MathContext(it, RoundingMode.HALF_EVEN)) }.first { it.toDouble() == number }.stripTrailingZeros()
99
+ if (kotlin.math.abs(number) >= 1e-6 && kotlin.math.abs(number) < 1e21) decimal.toPlainString()
100
+ else decimal.toString().lowercase().replace(Regex("e([+-]?)0+(\\d+)"), "e$1$2")
101
+ }
102
+ } else item.toString())
103
+ }
104
+ }
105
+ private fun jsonBudget(value: Any?) {
106
+ var count = 0
107
+ fun visit(value: Any?, depth: Int) {
108
+ if (++count > 10000 || depth > 20) throw IndianaClientException("invalid_request")
109
+ fun text(value: String) { if ('\u0000' in value || value.indices.any { (Character.isHighSurrogate(value[it]) && (it + 1 >= value.length || !Character.isLowSurrogate(value[it + 1]))) || (Character.isLowSurrogate(value[it]) && (it == 0 || !Character.isHighSurrogate(value[it - 1]))) }) throw IndianaClientException("invalid_request") }
110
+ when (value) {
111
+ is String -> text(value)
112
+ is JSONArray -> for (n in 0 until value.length()) visit(value.opt(n), depth + 1)
113
+ is JSONObject -> for (key in value.keys()) { text(key); if (key in listOf("__proto__", "constructor", "prototype")) throw IndianaClientException("invalid_request"); visit(value.opt(key), depth + 1) }
114
+ }
115
+ }
116
+ visit(value, 0)
117
+ val encoded = if (value is JSONObject || value is JSONArray) value.toString() else JSONArray().put(value).toString().drop(1).dropLast(1)
118
+ if (encoded.toByteArray(Charsets.UTF_8).size > 65536) throw IndianaClientException("invalid_request")
119
+ }
120
+ }
@@ -0,0 +1,106 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import java.time.Instant
4
+ import java.util.UUID
5
+ import java.util.concurrent.atomic.AtomicReference
6
+ import kotlinx.coroutines.CompletableDeferred
7
+ import org.json.JSONObject
8
+
9
+ /** Server-issued credentials. This SDK keeps the pair in memory only. */
10
+ data class IndianaCustomerSession(val customerId: UUID, val sessionId: UUID, val accessToken: String, val refreshToken: String, val expiresAt: Instant, val refreshExpiresAt: Instant) {
11
+ internal fun validate(fresh: Boolean = false) {
12
+ if (!accessToken.matches(Regex("gg_ct_[A-Za-z0-9_-]{43}")) || !refreshToken.matches(Regex("gg_rt_[A-Za-z0-9_-]{43}")) || refreshExpiresAt < expiresAt || refreshExpiresAt <= Instant.now() || (fresh && expiresAt <= Instant.now())) throw IndianaClientException("invalid_response")
13
+ }
14
+ companion object {
15
+ @JvmStatic fun fromJSON(value: JSONObject): IndianaCustomerSession = safe { IndianaCustomerSession(UUID.fromString(value.getString("customerId")), UUID.fromString(value.getString("sessionId")), value.getString("accessToken"), value.getString("refreshToken"), Instant.parse(value.getString("expiresAt")), Instant.parse(value.getString("refreshExpiresAt"))) }.also { it.validate() }
16
+ }
17
+ }
18
+ internal class IndianaSessionTransport(private val upstream: IndianaHTTP, private val expectedScope: IndianaScope, private val now: () -> Instant = Instant::now): IndianaHTTP {
19
+ private data class State(val original: String?, val pair: IndianaCustomerSession?, val epoch: UUID, val onInvalidated: (() -> Unit)? = null)
20
+ private val state = AtomicReference(State(null, null, UUID.randomUUID()))
21
+ private data class Flight(val state: State, val result: CompletableDeferred<IndianaCustomerSession>)
22
+ private val rotation = Any()
23
+ private var pending: Flight? = null
24
+ @Volatile private var accessProvider: (suspend () -> String)? = null
25
+ fun setAccessTokenProvider(provider: (suspend () -> String)?) { accessProvider = provider }
26
+ fun identify(access: String?, onInvalidated: (() -> Unit)? = null) { state.set(State(access, null, UUID.randomUUID(), onInvalidated)) }
27
+ fun identify(pair: IndianaCustomerSession, onInvalidated: (() -> Unit)? = null) { pair.validate(); state.set(State(pair.accessToken, pair, UUID.randomUUID(), onInvalidated)) }
28
+ private fun invalidate(current: State) {
29
+ if (state.compareAndSet(current, State(null, null, UUID.randomUUID()))) current.onInvalidated?.invoke()
30
+ }
31
+ suspend fun refresh(force: Boolean = true): IndianaCustomerSession {
32
+ if (accessProvider != null) throw IndianaClientException("unsupported_capability")
33
+ val observed = state.get()
34
+ var owner = false
35
+ val flight = synchronized(rotation) {
36
+ val current = state.get()
37
+ if (current.epoch != observed.epoch) throw IndianaClientException("identity_changed")
38
+ val pair = current.pair ?: throw IndianaClientException("unauthenticated")
39
+ if (current.pair != observed.pair || (!force && pair.expiresAt > now().plusSeconds(30))) return pair
40
+ if (pair.refreshExpiresAt <= now()) {
41
+ invalidate(current)
42
+ throw IndianaClientException("unauthenticated")
43
+ }
44
+ pending?.takeIf { it.state.epoch == current.epoch } ?: Flight(current, CompletableDeferred()).also { pending = it; owner = true }
45
+ }
46
+ if (!owner) {
47
+ try {
48
+ val next = flight.result.await()
49
+ if (state.get().epoch != flight.state.epoch) throw IndianaClientException("identity_changed")
50
+ return next
51
+ } catch (error: Exception) {
52
+ if (state.get().epoch != flight.state.epoch) throw IndianaClientException("identity_changed")
53
+ throw error
54
+ }
55
+ }
56
+ val current = flight.state
57
+ val pair = current.pair ?: throw IndianaClientException("unauthenticated")
58
+ try {
59
+ val result = upstream.send("customer-sessions/refresh", "POST", pair.refreshToken, JSONObject())
60
+ if (safe { scope(result.getJSONObject("scope")) } != expectedScope) throw IndianaClientException("scope_mismatch")
61
+ val next = IndianaCustomerSession.fromJSON(result); next.validate(true)
62
+ if (next.customerId != pair.customerId || next.sessionId != pair.sessionId) throw IndianaClientException("scope_mismatch")
63
+ if (!state.compareAndSet(current, current.copy(pair = next))) throw IndianaClientException("identity_changed")
64
+ flight.result.complete(next); return next
65
+ } catch (cause: Exception) {
66
+ val error = if (state.get().epoch != current.epoch) IndianaClientException("identity_changed") else cause
67
+ if (error is IndianaClientException && error.code == "request_failed_401") invalidate(current)
68
+ flight.result.completeExceptionally(error); throw error
69
+ } finally { synchronized(rotation) { if (pending === flight) pending = null } }
70
+ }
71
+ suspend fun access(): String {
72
+ val current = state.get()
73
+ accessProvider?.let { provider ->
74
+ if (current.original == null) throw IndianaClientException("unauthenticated")
75
+ val token = provider()
76
+ if (state.get().epoch != current.epoch) throw IndianaClientException("identity_changed")
77
+ if (!token.matches(Regex("gg_ct_[A-Za-z0-9_-]{43}"))) throw IndianaClientException("invalid_response")
78
+ return token
79
+ }
80
+ if (current.pair != null && current.pair.expiresAt <= now().plusSeconds(30)) refresh(false)
81
+ val active = state.get()
82
+ if (current.epoch != active.epoch) throw IndianaClientException("identity_changed")
83
+ return active.pair?.accessToken ?: active.original ?: throw IndianaClientException("unauthenticated")
84
+ }
85
+ override suspend fun send(path: String, method: String, authorization: String, body: JSONObject?): JSONObject {
86
+ val actor = state.get()
87
+ val proof = body?.optString("customerToken")
88
+ if (path.startsWith("push/") && body?.has("credentialDigest") == true) {
89
+ if (actor.original == null) throw IndianaClientException("unauthenticated")
90
+ val result = upstream.send(path, method, authorization, body)
91
+ if (state.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
92
+ return result
93
+ }
94
+ if (actor.original == null && (authorization.startsWith("gg_ct_") || proof?.startsWith("gg_ct_") == true)) throw IndianaClientException("unauthenticated")
95
+ val customer = actor.original != null && (authorization == actor.original || proof == actor.original)
96
+ if (!customer) return upstream.send(path, method, authorization, body)
97
+ val token = access()
98
+ val active = state.get()
99
+ if (active.epoch != actor.epoch) throw IndianaClientException("identity_changed")
100
+ val payload = body?.let { JSONObject(it.toString()) }
101
+ if (proof == actor.original) payload?.put("customerToken", token)
102
+ val result = upstream.send(path, method, if (authorization == actor.original) token else authorization, payload)
103
+ if (state.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
104
+ return result
105
+ }
106
+ }
@@ -0,0 +1,65 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import android.app.Activity
4
+ import kotlinx.coroutines.CompletableDeferred
5
+ import kotlinx.coroutines.CoroutineScope
6
+ import kotlinx.coroutines.Dispatchers
7
+ import kotlinx.coroutines.SupervisorJob
8
+ import kotlinx.coroutines.cancel
9
+ import kotlinx.coroutines.withContext
10
+ import kotlinx.coroutines.withTimeout
11
+ import org.json.JSONObject
12
+ import java.util.UUID
13
+
14
+ /** Customer-scoped GB evaluator using the packaged official engine, without JS credentials. */
15
+ class IndianaFeatureClient internal constructor(private val activity: Activity, private val configuration: IndianaConfiguration, attributes: JSONObject, private val operation: suspend (IndianaCustomerOperation, JSONObject) -> JSONObject) {
16
+ private val attributes = JSONObject(attributes.toString())
17
+ private val work = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
18
+ private val readiness = CompletableDeferred<Unit>()
19
+ private val pending = mutableMapOf<UUID, CompletableDeferred<Any?>>()
20
+ var onError: ((Exception) -> Unit)? = null
21
+ private var stopped = false
22
+ private val host = IndianaBundledWebView(activity, "Indiana Feature evaluator", work, ::dispatch) { error -> if (!readiness.isCompleted) close() else onError?.invoke(error) }
23
+ init { host.start() }
24
+ suspend fun evalFeature(key: String): JSONObject {
25
+ val result = call(JSONObject().put("method", "evalFeature").put("key", key)) as? JSONObject ?: throw IndianaClientException("invalid_response")
26
+ IndianaSchema.validate("nativeFeatureResult", result); return JSONObject(result.toString())
27
+ }
28
+ suspend fun isOn(key: String): Boolean = evalFeature(key).getBoolean("on")
29
+ suspend fun isOff(key: String): Boolean = evalFeature(key).getBoolean("off")
30
+ suspend fun getFeatureValue(key: String, fallback: Any? = JSONObject.NULL): Any? = call(JSONObject().put("method", "getFeatureValue").put("key", key).put("fallback", fallback ?: JSONObject.NULL))
31
+ suspend fun setAttributes(attributes: JSONObject) { call(JSONObject().put("method", "setAttributes").put("attributes", JSONObject(attributes.toString()))) }
32
+ suspend fun refresh() { call(JSONObject().put("method", "refresh")) }
33
+ suspend fun flush() { call(JSONObject().put("method", "flush")) }
34
+ fun close() { activity.runOnUiThread {
35
+ if (!stopped) {
36
+ stopped = true; host.destroy()
37
+ val error = IndianaClientException("identity_changed")
38
+ readiness.completeExceptionally(error); pending.values.forEach { it.completeExceptionally(error) }; pending.clear(); work.cancel()
39
+ }
40
+ } }
41
+ private suspend fun call(input: JSONObject): Any? = withContext(Dispatchers.Main.immediate) {
42
+ if (stopped) throw IndianaClientException("identity_changed")
43
+ withTimeout(45_000) { readiness.await() }
44
+ if (stopped) throw IndianaClientException("identity_changed")
45
+ val id = UUID.randomUUID(); val result = CompletableDeferred<Any?>(); pending[id] = result
46
+ try { host.send(JSONObject().put("event", "feature_call").put("id", id.toString()).put("input", input)); withTimeout(45_000) { result.await() } }
47
+ finally { pending.remove(id) }
48
+ }
49
+ private suspend fun dispatch(method: String, raw: Any?): Any? = when (method) {
50
+ "ready" -> { host.send(JSONObject().put("event", "init").put("surface", "features").put("scope", JSONObject().put("projectId", configuration.scope.projectId.toString()).put("environment", configuration.scope.environment.value)).put("apiOrigin", configuration.apiOrigin).put("attributes", attributes)); null }
51
+ "featureOperation" -> {
52
+ val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
53
+ val name = value.getString("operation")
54
+ if (stopped || name !in setOf("getFeatures", "evaluateFeatures", "trackEvents")) throw IndianaClientException("invalid_command")
55
+ operation(IndianaCustomerOperation.valueOf(name), value.getJSONObject("input"))
56
+ }
57
+ "featuresReady" -> { if (stopped) throw IndianaClientException("identity_changed"); readiness.complete(Unit); null }
58
+ "featureResult" -> {
59
+ val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
60
+ val response = pending.remove(UUID.fromString(value.getString("id")))
61
+ if (value.has("error")) response?.completeExceptionally(IndianaClientException("invalid_response")) else response?.complete(value.opt("result")); null
62
+ }
63
+ else -> throw IndianaClientException("invalid_command")
64
+ }
65
+ }
@@ -0,0 +1,163 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import android.content.Context
4
+ import android.security.keystore.KeyGenParameterSpec
5
+ import android.security.keystore.KeyProperties
6
+ import android.util.AtomicFile
7
+ import android.util.Base64
8
+ import kotlinx.coroutines.Dispatchers
9
+ import kotlinx.coroutines.withContext
10
+ import kotlinx.coroutines.sync.Mutex
11
+ import kotlinx.coroutines.sync.withLock
12
+ import org.json.JSONObject
13
+ import java.io.File
14
+ import java.io.RandomAccessFile
15
+ import java.security.KeyStore
16
+ import java.security.MessageDigest
17
+ import javax.crypto.Cipher
18
+ import javax.crypto.KeyGenerator
19
+ import javax.crypto.SecretKey
20
+ import javax.crypto.spec.GCMParameterSpec
21
+
22
+ data class IndianaFunnelStorageSnapshot(val revision: Long, val entry: String?)
23
+ /** One original Run per adapter; revision CAS must be atomic across hosts. */
24
+ interface IndianaFunnelCommandStorage {
25
+ fun read(): IndianaFunnelStorageSnapshot
26
+ fun write(expectedRevision: Long, entry: String?): Long?
27
+ }
28
+
29
+ /** Encrypted, atomic, excluded from backup. Stores no Run/Customer credential. */
30
+ internal class IndianaFunnelCommandKeystore(context: Context, configuration: IndianaConfiguration, runToken: String): IndianaFunnelCommandStorage {
31
+ private val namespace = "${context.packageName}:${configuration.baseUrl}:${configuration.scope.projectId}:${configuration.scope.environment.value}:$runToken"
32
+ private val hash = MessageDigest.getInstance("SHA-256").digest(namespace.toByteArray()).joinToString("") { "%02x".format(it) }
33
+ private val directory = File(context.noBackupFilesDir, "indiana-funnel-commands-$hash")
34
+ private val file = AtomicFile(File(directory, "snapshot"))
35
+ private val alias = "indiana.funnel.commands.v1.$hash"
36
+ private fun exists() = file.baseFile.exists() || File(file.baseFile.path + ".bak").exists()
37
+ companion object { private val lock = Any() }
38
+ private fun <T> locked(action: () -> T): T = synchronized(lock) {
39
+ try {
40
+ if (!directory.exists() && !directory.mkdirs()) throw IndianaClientException("protected_storage_unavailable")
41
+ RandomAccessFile(File(directory,"lock"), "rw").use { handle -> handle.channel.lock().use { action() } }
42
+ } catch (error: IndianaClientException) { throw error }
43
+ catch (_: Exception) { throw IndianaClientException("protected_storage_unavailable") }
44
+ }
45
+ private fun key(create: Boolean): SecretKey {
46
+ val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
47
+ (store.getKey(alias, null) as? SecretKey)?.let { return it }
48
+ if (!create || exists()) throw IndianaClientException("protected_storage_unavailable")
49
+ val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).setKeySize(256).setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setRandomizedEncryptionRequired(true).build()
50
+ return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,"AndroidKeyStore").apply { init(spec) }.generateKey()
51
+ }
52
+ private fun stored(): IndianaFunnelStorageSnapshot {
53
+ if (!exists()) return IndianaFunnelStorageSnapshot(0, null)
54
+ val bytes = file.openRead().use { input ->
55
+ val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(8192)
56
+ while (true) { val n = input.read(buffer); if (n < 0) break; if (output.size()+n > 1_048_576) throw IndianaClientException("protected_storage_unavailable"); output.write(buffer,0,n) }; output.toByteArray()
57
+ }
58
+ val encrypted = JSONObject(bytes.toString(Charsets.UTF_8))
59
+ if (encrypted.keys().asSequence().toSet() != setOf("format","nonce","ciphertext") || encrypted.getString("format") != "indiana_funnel_commands_v1") throw IndianaClientException("protected_storage_unavailable")
60
+ val nonce = Base64.decode(encrypted.getString("nonce"),Base64.NO_WRAP)
61
+ if (nonce.size != 12) throw IndianaClientException("protected_storage_unavailable")
62
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE,key(false),GCMParameterSpec(128,nonce)); cipher.updateAAD(hash.toByteArray())
63
+ val plain = cipher.doFinal(Base64.decode(encrypted.getString("ciphertext"),Base64.NO_WRAP))
64
+ try {
65
+ val value = JSONObject(plain.toString(Charsets.UTF_8)); val revision = value.getLong("revision")
66
+ if (revision < 0 || value.keys().asSequence().toSet() != setOf("revision","entry") || !value.isNull("entry") && value.optJSONObject("entry") == null) throw IndianaClientException("protected_storage_unavailable")
67
+ return IndianaFunnelStorageSnapshot(revision, value.optJSONObject("entry")?.toString())
68
+ } finally { plain.fill(0) }
69
+ }
70
+ override fun read(): IndianaFunnelStorageSnapshot = locked { stored() }
71
+ override fun write(expectedRevision: Long, entry: String?): Long? = locked {
72
+ if (stored().revision != expectedRevision) return@locked null
73
+ if (expectedRevision == Long.MAX_VALUE || (entry?.toByteArray()?.size ?: 0) > 600_000) throw IndianaClientException("protected_storage_unavailable")
74
+ val plain = JSONObject().put("revision",expectedRevision+1).put("entry",entry?.let { JSONObject(it) } ?: JSONObject.NULL).toString().toByteArray()
75
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE,key(true)); cipher.updateAAD(hash.toByteArray())
76
+ try {
77
+ val bytes = JSONObject().put("format","indiana_funnel_commands_v1").put("nonce",Base64.encodeToString(cipher.iv,Base64.NO_WRAP)).put("ciphertext",Base64.encodeToString(cipher.doFinal(plain),Base64.NO_WRAP)).toString().toByteArray()
78
+ val stream = file.startWrite()
79
+ try { stream.write(bytes); file.finishWrite(stream) } catch (error: Exception) { file.failWrite(stream); throw error }
80
+ } finally { plain.fill(0) }
81
+ expectedRevision+1
82
+ }
83
+ }
84
+
85
+ internal class IndianaFunnelCommandJournal(private val storage: IndianaFunnelCommandStorage, private val scope: IndianaScope) {
86
+ private val updates = Mutex()
87
+ @Volatile private var revision = 0L
88
+ @Volatile private var binding: JSONObject? = null
89
+ private fun same(a: JSONObject, b: JSONObject) = IndianaSchema.equal(a,b)
90
+ private fun matches(entry: JSONObject, run: JSONObject): Boolean = entry.getString("runId") == run.getString("id") && entry.getString("funnelId") == run.getJSONObject("source").getString("id") && entry.getString("versionId") == run.getJSONObject("source").getString("versionId")
91
+ private fun read(): Pair<Long,JSONObject?> {
92
+ val saved = storage.read()
93
+ if (saved.revision < 0 || (saved.entry?.toByteArray()?.size ?: 0) > 600_000) throw IndianaClientException("invalid_command_storage")
94
+ val value = saved.entry?.let { JSONObject(it) } ?: return saved.revision to null
95
+ val allowed = setOf("schemaVersion","scope","runId","funnelId","versionId","command","closeCommand","purchase","closed")
96
+ if (value.keys().asSequence().any { it !in allowed } || value.getInt("schemaVersion") != 1 || value.getJSONObject("scope").getString("projectId") != scope.projectId.toString() || value.getJSONObject("scope").getString("environment") != scope.environment.value || value.optJSONObject("command") == null && value.optJSONObject("purchase") == null) throw IndianaClientException("invalid_command_storage")
97
+ for (field in listOf("runId","funnelId","versionId")) java.util.UUID.fromString(value.getString(field))
98
+ for (field in listOf("command","closeCommand")) value.optJSONObject(field)?.let { command ->
99
+ if (command.has("customerToken") || command.optString("operation") in setOf("start","get","review")) throw IndianaClientException("invalid_command_storage")
100
+ IndianaSchema.validate("command",command)
101
+ }
102
+ value.optJSONObject("closeCommand")?.let { close -> val command = value.optJSONObject("command") ?: throw IndianaClientException("invalid_command_storage"); if (close.optString("operation") != "close" || command.optString("operation") == "close" || close.getString("requestId") == command.getString("requestId")) throw IndianaClientException("invalid_command_storage") }
103
+ value.optJSONObject("purchase")?.let { purchase ->
104
+ if (purchase.keys().asSequence().toSet() != setOf("receiptId","operationId","generation") || purchase.getLong("generation") < 0) throw IndianaClientException("invalid_command_storage")
105
+ java.util.UUID.fromString(purchase.getString("receiptId")); java.util.UUID.fromString(purchase.getString("operationId"))
106
+ }
107
+ return saved.revision to value
108
+ }
109
+ suspend fun load(): JSONObject? = updates.withLock { withContext(Dispatchers.IO) { val saved = read(); revision = saved.first; binding = saved.second; saved.second?.let { JSONObject(it.toString()) } } }
110
+ fun bind(run: JSONObject) { binding?.let { if (!matches(it,run)) throw IndianaClientException("invalid_response") } }
111
+ private suspend fun update(change: (JSONObject?,Long) -> JSONObject?) = updates.withLock { withContext(Dispatchers.IO) {
112
+ var written = false
113
+ for (attempt in 0..3) {
114
+ val (currentRevision,saved) = read()
115
+ binding?.let { original -> if (saved != null && (original.getString("runId") != saved.getString("runId") || original.getString("funnelId") != saved.getString("funnelId") || original.getString("versionId") != saved.getString("versionId"))) throw IndianaClientException("command_storage_conflict") }
116
+ val original = saved?.let { JSONObject(it.toString()) }
117
+ val next = change(saved,currentRevision)
118
+ if (next == null && original == null || next != null && original != null && same(next,original)) { revision = currentRevision; written = true; break }
119
+ val nextRevision = storage.write(currentRevision,next?.toString()) ?: continue
120
+ revision = nextRevision; binding = next ?: binding; written = true; break
121
+ }
122
+ if (!written) throw IndianaClientException("command_storage_conflict")
123
+ } }
124
+ private fun fresh(run: JSONObject) = JSONObject().put("schemaVersion",1).put("scope",JSONObject().put("projectId",scope.projectId.toString()).put("environment",scope.environment.value)).put("runId",run.getString("id")).put("funnelId",run.getJSONObject("source").getString("id")).put("versionId",run.getJSONObject("source").getString("versionId")).put("closed",false)
125
+ suspend fun retain(command: JSONObject, run: JSONObject) {
126
+ if (command.has("customerToken") || command.toString().toByteArray().size > 262144 || command.optString("operation") in setOf("start","get","review")) throw IndianaClientException("invalid_command")
127
+ IndianaSchema.validate("command",command)
128
+ bind(run); val known = revision
129
+ update { saved,currentRevision ->
130
+ val value = saved ?: fresh(run); val original = value.optJSONObject("command")
131
+ if (!matches(value,run) || saved == null && currentRevision != known && command.optString("operation") != "close") throw IndianaClientException("command_storage_conflict")
132
+ if (command.optString("operation") == "close") {
133
+ value.put("closed",true)
134
+ if (original != null && original.optString("operation") != "close") {
135
+ val previous = value.optJSONObject("closeCommand")
136
+ if (original.getString("requestId") == command.getString("requestId") || previous != null && !same(previous,command)) throw IndianaClientException("command_storage_conflict")
137
+ value.put("closeCommand",JSONObject(command.toString()))
138
+ } else { if (original != null && !same(original,command)) throw IndianaClientException("command_storage_conflict"); value.put("command",JSONObject(command.toString())) }
139
+ } else { if (original != null && !same(original,command)) throw IndianaClientException("command_storage_conflict"); value.put("command",JSONObject(command.toString())) }
140
+ value
141
+ }
142
+ }
143
+ suspend fun purchase(purchase: JSONObject, terminal: Boolean, run: JSONObject) {
144
+ bind(run); val known = revision
145
+ update { saved,currentRevision ->
146
+ if (saved == null && currentRevision != known) throw IndianaClientException("command_storage_conflict")
147
+ val value = saved ?: fresh(run); val previous = value.optJSONObject("purchase")
148
+ if (previous != null) {
149
+ if (previous.getLong("generation") > purchase.getLong("generation")) return@update saved
150
+ if (previous.getLong("generation") == purchase.getLong("generation") && previous.getString("operationId") != purchase.getString("operationId")) throw IndianaClientException("command_storage_conflict")
151
+ }
152
+ if (terminal) { if (previous?.getString("operationId") == purchase.getString("operationId")) value.remove("purchase") }
153
+ else value.put("purchase",JSONObject(purchase.toString()))
154
+ if (value.optJSONObject("command") == null && value.optJSONObject("purchase") == null) null else value
155
+ }
156
+ }
157
+ suspend fun settle(id: String) = update { saved,_ ->
158
+ if (saved == null) return@update null
159
+ if (saved.optJSONObject("command")?.optString("requestId") == id) { val close = saved.optJSONObject("closeCommand"); if (close == null) saved.remove("command") else saved.put("command",close); saved.remove("closeCommand") }
160
+ else if (saved.optJSONObject("closeCommand")?.optString("requestId") == id) saved.remove("closeCommand")
161
+ if (saved.optJSONObject("command") == null && saved.optJSONObject("purchase") == null) null else saved
162
+ }
163
+ }
@@ -0,0 +1,50 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import kotlinx.coroutines.Dispatchers
4
+ import kotlinx.coroutines.withContext
5
+ import org.json.JSONObject
6
+ import java.io.ByteArrayOutputStream
7
+ import java.net.HttpURLConnection
8
+ import java.net.URI
9
+ import java.nio.ByteBuffer
10
+ import java.nio.charset.CodingErrorAction
11
+
12
+ /** The Funnel shell has no network access (connect-src 'none'). For Local rules it asks the native host for public
13
+ * configuration: one exact GET, without credentials, cookies or redirects. */
14
+ internal class IndianaFunnelConfigurationRelay(private val configuration: IndianaConfiguration, loopbackFixtureOrigin: URI? = null) {
15
+ // Internal test seam: the public configuration remains HTTPS-only.
16
+ private val origin = loopbackFixtureOrigin?.also { require(it.scheme == "http" && it.host == "127.0.0.1" && it.port > 1024 && it.rawUserInfo == null && it.rawQuery == null && it.rawFragment == null && it.rawPath.isNullOrEmpty()) } ?: URI(configuration.baseUrl)
17
+ suspend fun read(payload: Any?): JSONObject {
18
+ val request = payload as? JSONObject
19
+ val path = request?.opt("path") as? String
20
+ if (request == null || request.length() != 1 || path == null || !PATH.matches(path)) throw IndianaClientException("invalid_command")
21
+ return withContext(Dispatchers.IO) {
22
+ val connection = origin.resolve(path).toURL().openConnection() as HttpURLConnection
23
+ try {
24
+ connection.requestMethod = "GET"; connection.instanceFollowRedirects = false; connection.useCaches = path.contains("/artifacts/")
25
+ connection.connectTimeout = configuration.timeoutMs; connection.readTimeout = configuration.timeoutMs
26
+ val status = connection.responseCode
27
+ val headers = JSONObject()
28
+ for ((key, values) in connection.headerFields) {
29
+ val name = key?.lowercase() ?: continue
30
+ if (name in HEADERS && values.isNotEmpty()) headers.put(name, values.joinToString(", "))
31
+ }
32
+ // The shell reports other statuses (including 429 Retry-After) by status alone.
33
+ val body = if (status in 200..299) {
34
+ if ((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0) > MAX_BYTES) throw IndianaClientException("invalid_response")
35
+ val output = ByteArrayOutputStream()
36
+ connection.inputStream.use { input -> val buffer = ByteArray(8192); while (true) { val size = input.read(buffer); if (size < 0) break; if (output.size() + size > MAX_BYTES) throw IndianaClientException("invalid_response"); output.write(buffer, 0, size) } }
37
+ try { Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(output.toByteArray())).toString() }
38
+ catch (_: Exception) { throw IndianaClientException("invalid_response") }
39
+ } else ""
40
+ JSONObject().put("status", status).put("headers", headers).put("body", body)
41
+ } catch (e: IndianaClientException) { throw e } catch (_: Exception) { throw IndianaClientException("temporary_unavailable") }
42
+ finally { connection.disconnect() }
43
+ }
44
+ }
45
+ companion object {
46
+ const val MAX_BYTES = 1_048_576
47
+ private val PATH = Regex("/api/v1/configuration/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(/artifacts/[a-f0-9]{64})?")
48
+ private val HEADERS = setOf("age", "content-type", "date", "etag", "retry-after", "x-indiana-config-valid-until", "x-indiana-config-validated-at")
49
+ }
50
+ }