@tinkerlandai/indiana-react-native 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/IndianaReactNative.podspec +19 -0
  2. package/README.md +62 -5
  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 +96889 -6007
  10. package/dist/index.d.ts +96889 -6007
  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 +197 -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/MerchantCommerceSemantics.kt +38 -0
  33. package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
  34. package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
  35. package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
  36. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
  37. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
  38. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
  39. package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
  40. package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
  41. package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
  42. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
  43. package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
  44. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
  45. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
  46. package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
  47. package/native-sdk/android/resources/renderer-data-url.txt +1 -0
  48. package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
  49. package/native-sdk/ios/AppleStorefront.swift +218 -0
  50. package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
  51. package/native-sdk/ios/BundledWebView.swift +87 -0
  52. package/native-sdk/ios/ContentSession.swift +48 -0
  53. package/native-sdk/ios/ContentView.swift +51 -0
  54. package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
  55. package/native-sdk/ios/CustomerResponseSemantics.swift +236 -0
  56. package/native-sdk/ios/CustomerSemantics.swift +145 -0
  57. package/native-sdk/ios/CustomerSession.swift +129 -0
  58. package/native-sdk/ios/FeatureClient.swift +86 -0
  59. package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
  60. package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
  61. package/native-sdk/ios/FunnelSession.swift +232 -0
  62. package/native-sdk/ios/FunnelView.swift +125 -0
  63. package/native-sdk/ios/GeneratedProtocol.swift +16 -0
  64. package/native-sdk/ios/HTTPTransport.swift +74 -0
  65. package/native-sdk/ios/IndianaClient.swift +601 -0
  66. package/native-sdk/ios/MerchantCommerceSemantics.swift +51 -0
  67. package/native-sdk/ios/NativePendingKeychain.swift +52 -0
  68. package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
  69. package/native-sdk/ios/NativeStoreOffer.swift +34 -0
  70. package/native-sdk/ios/PaywallContent.swift +75 -0
  71. package/native-sdk/ios/PaywallSession.swift +124 -0
  72. package/native-sdk/ios/PaywallView.swift +87 -0
  73. package/native-sdk/ios/Protocol.swift +49 -0
  74. package/native-sdk/ios/PushInstallation.swift +154 -0
  75. package/native-sdk/ios/RendererNavigation.swift +8 -0
  76. package/native-sdk/ios/Resources.swift +15 -0
  77. package/native-sdk/ios/SDKVersion.swift +4 -0
  78. package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
  79. package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
  80. package/native-sdk/ios/WebView/index.html +268 -0
  81. package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
  82. package/native-sdk/ios/WireSchema.swift +107 -0
  83. package/native-sdk/ios/funnel-wire-schemas.json +1 -0
  84. package/package.json +8 -3
  85. package/react-native.config.cjs +12 -0
@@ -0,0 +1,81 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import android.content.Context
4
+ import android.security.keystore.KeyGenParameterSpec
5
+ import android.security.keystore.KeyProperties
6
+ import android.util.AtomicFile
7
+ import android.util.Base64
8
+ import org.json.JSONObject
9
+ import java.io.File
10
+ import java.security.KeyStore
11
+ import java.security.MessageDigest
12
+ import java.util.UUID
13
+ import javax.crypto.Cipher
14
+ import javax.crypto.KeyGenerator
15
+ import javax.crypto.SecretKey
16
+ import javax.crypto.spec.GCMParameterSpec
17
+
18
+ internal data class IndianaNativePendingOrder(val runId: UUID, val orderId: UUID, val receiptId: UUID, val productId: String, val accountId: String, val profileId: String, val basePlanId: String?, val offerId: String?) {
19
+ fun json(): JSONObject = JSONObject().put("runId", runId.toString()).put("orderId", orderId.toString()).put("receiptId", receiptId.toString()).put("productId", productId).put("obfuscatedAccountId", accountId).put("obfuscatedProfileId", profileId).put("basePlanId", basePlanId ?: JSONObject.NULL).put("offerId", offerId ?: JSONObject.NULL)
20
+ companion object {
21
+ fun parse(value: JSONObject, runId: UUID = UUID.fromString(value.getString("runId"))): IndianaNativePendingOrder {
22
+ val result = IndianaNativePendingOrder(runId, UUID.fromString(value.getString("orderId")), UUID.fromString(value.getString("receiptId")), value.getString("productId"), value.getString("obfuscatedAccountId"), value.getString("obfuscatedProfileId"), value.optString("basePlanId").takeIf { value.has("basePlanId") && !value.isNull("basePlanId") }, value.optString("offerId").takeIf { value.has("offerId") && !value.isNull("offerId") })
23
+ if (result.productId.length !in 1..255 || result.accountId.length !in 1..64 || result.profileId.length !in 1..64) throw IndianaClientException("invalid_response")
24
+ return result
25
+ }
26
+ }
27
+ }
28
+ /** Current order metadata only. No token/JWS/customer credential or legacy storage is read. */
29
+ internal interface IndianaNativePendingStorage {
30
+ fun read(order: UUID): IndianaNativePendingOrder?
31
+ fun orders(): List<IndianaNativePendingOrder>
32
+ fun remove(order: UUID)
33
+ }
34
+ internal class IndianaNativePendingKeystore(context: Context, scope: IndianaScope): IndianaNativePendingStorage {
35
+ private val namespace = "${context.packageName}:${scope.projectId}:${scope.environment.value}"
36
+ private val hash = MessageDigest.getInstance("SHA-256").digest(namespace.toByteArray()).joinToString("") { "%02x".format(it) }
37
+ private val directory = File(context.noBackupFilesDir, "grow-funnel-orders-$hash")
38
+ private val alias = "grow.funnel.orders.v1.$hash"
39
+ companion object { private val lock = Any() }
40
+ private fun file(order: UUID) = AtomicFile(File(directory, "$order.json"))
41
+ private fun aad(order: UUID) = "grow_funnel_order_v1\u0000$namespace\u0000$order".toByteArray()
42
+ private fun key(create: Boolean): SecretKey {
43
+ val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
44
+ (store.getKey(alias, null) as? SecretKey)?.let { return it }
45
+ if (!create || directory.listFiles()?.any { it.isFile } == true) throw IndianaClientException("protected_storage_unavailable")
46
+ val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).setKeySize(256).setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setRandomizedEncryptionRequired(true).build()
47
+ return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { init(spec) }.generateKey()
48
+ }
49
+ override fun read(order: UUID): IndianaNativePendingOrder? = synchronized(lock) {
50
+ try {
51
+ if (!file(order).baseFile.exists()) return@synchronized null
52
+ val bytes = file(order).openRead().use { input -> val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(1024); while (true) { val count = input.read(buffer); if (count < 0) break; if (output.size() + count > 8192) throw IndianaClientException("protected_storage_unavailable"); output.write(buffer, 0, count) }; output.toByteArray() }
53
+ val value = JSONObject(bytes.toString(Charsets.UTF_8)); if (value.keys().asSequence().toSet() != setOf("format", "nonce", "ciphertext") || value.getString("format") != "grow_funnel_order_v1") throw IndianaClientException("protected_storage_unavailable")
54
+ val nonce = Base64.decode(value.getString("nonce"), Base64.NO_WRAP); if (nonce.size != 12) throw IndianaClientException("protected_storage_unavailable")
55
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, key(false), GCMParameterSpec(128, nonce)); cipher.updateAAD(aad(order))
56
+ val plaintext = cipher.doFinal(Base64.decode(value.getString("ciphertext"), Base64.NO_WRAP))
57
+ try { IndianaNativePendingOrder.parse(JSONObject(plaintext.toString(Charsets.UTF_8))).also { if (it.orderId != order) throw IndianaClientException("protected_storage_unavailable") } } finally { plaintext.fill(0) }
58
+ } catch (_: Exception) { throw IndianaClientException("protected_storage_unavailable") }
59
+ }
60
+ override fun orders(): List<IndianaNativePendingOrder> = synchronized(lock) {
61
+ val files = directory.listFiles()?.filter { it.name.endsWith(".json") } ?: emptyList()
62
+ if (files.size > 100) throw IndianaClientException("protected_storage_unavailable")
63
+ files.mapNotNull { read(UUID.fromString(it.name.removeSuffix(".json"))) }
64
+ }
65
+ fun orders(runId: UUID): List<IndianaNativePendingOrder> = orders().filter { it.runId == runId }
66
+ fun contains(record: IndianaNativePendingOrder): Boolean { val previous = read(record.orderId) ?: return false; if (previous != record) throw IndianaClientException("protected_storage_unavailable"); return true }
67
+ fun put(record: IndianaNativePendingOrder) = synchronized(lock) {
68
+ if (contains(record)) return@synchronized
69
+ if ((directory.listFiles()?.count { it.name.endsWith(".json") } ?: 0) >= 100) throw IndianaClientException("protected_storage_unavailable")
70
+ val currentKey = key(true)
71
+ if (!directory.exists() && !directory.mkdirs()) throw IndianaClientException("protected_storage_unavailable")
72
+ val plaintext = record.json().toString().toByteArray()
73
+ try {
74
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, currentKey); cipher.updateAAD(aad(record.orderId))
75
+ val bytes = JSONObject().put("format", "grow_funnel_order_v1").put("nonce", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)).put("ciphertext", Base64.encodeToString(cipher.doFinal(plaintext), Base64.NO_WRAP)).toString().toByteArray()
76
+ val atomic = file(record.orderId); val stream = atomic.startWrite()
77
+ try { stream.write(bytes); atomic.finishWrite(stream) } catch (error: Exception) { atomic.failWrite(stream); throw error }
78
+ } finally { plaintext.fill(0) }
79
+ }
80
+ override fun remove(order: UUID) = synchronized(lock) { file(order).delete() }
81
+ }
@@ -0,0 +1,28 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import kotlinx.coroutines.Dispatchers
4
+ import kotlinx.coroutines.withContext
5
+ import org.json.JSONObject
6
+ import java.net.URI
7
+ import java.net.HttpURLConnection
8
+ import java.io.ByteArrayOutputStream
9
+
10
+ /** The endpoint is your trusted app backend. No Indiana server API key belongs on the device. */
11
+ class IndianaNativeReceiptRelay(endpoint: String) {
12
+ private val url = URI(endpoint).also { require(it.scheme == "https" && it.host != null && it.rawUserInfo == null && it.rawQuery == null && it.rawFragment == null) }.toURL()
13
+ suspend fun send(body: JSONObject): JSONObject = withContext(Dispatchers.IO) {
14
+ val token = body.opt("customerToken") as? String ?: throw IndianaClientException("unauthenticated")
15
+ val bytes = body.toString().toByteArray(Charsets.UTF_8)
16
+ if (body.optString("operation") != "native_receipt" || !token.matches(Regex("gg_ct_[A-Za-z0-9_-]{32,128}")) || bytes.size > 262144) throw IndianaClientException("invalid_command")
17
+ val connection = url.openConnection() as HttpURLConnection
18
+ try {
19
+ connection.requestMethod = "POST"; connection.instanceFollowRedirects = false; connection.useCaches = false; connection.connectTimeout = 35000; connection.readTimeout = 35000
20
+ connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Authorization", "Bearer $token")
21
+ connection.doOutput = true; connection.setFixedLengthStreamingMode(bytes.size); connection.outputStream.use { it.write(bytes) }
22
+ if (connection.responseCode !in 200..299) throw IndianaClientException("request_failed_${connection.responseCode}")
23
+ if ((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0) > 262144) throw IndianaClientException("invalid_response")
24
+ val output = ByteArrayOutputStream(); connection.inputStream.use { input -> val buffer = ByteArray(8192); while (true) { val count = input.read(buffer); if (count < 0) break; if (output.size() + count > 262144) throw IndianaClientException("invalid_response"); output.write(buffer,0,count) } }
25
+ safe { JSONObject(output.toString("UTF-8")) }
26
+ } catch (error: IndianaClientException) { throw error } catch (_: Exception) { throw IndianaClientException("temporary_unavailable") } finally { connection.disconnect() }
27
+ }
28
+ }
@@ -0,0 +1,24 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import org.json.JSONObject
4
+
5
+ enum class IndianaNativeOfferKind(val value: String) { NONE("none"), INTRODUCTORY("introductory"), PROMOTIONAL("promotional") }
6
+
7
+ /** Selected store terms are input evidence until the backend verifies the transaction. */
8
+ data class IndianaNativeStoreOffer(val offerKind: IndianaNativeOfferKind, val offerId: String? = null, val discountMinor: Long = 0, val trialDays: Int? = null) {
9
+ init {
10
+ require(discountMinor in 0..100000000)
11
+ require(trialDays == null || trialDays in 1..730)
12
+ require(offerId == null || offerId.matches(Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,254}")))
13
+ require(trialDays == null || discountMinor == 0L)
14
+ require(offerKind != IndianaNativeOfferKind.NONE || (offerId == null && discountMinor == 0L && trialDays == null))
15
+ }
16
+ internal fun wire(): JSONObject = JSONObject().put("offerKind", offerKind.value).put("offerId", offerId ?: JSONObject.NULL)
17
+ .put("discountMinor", discountMinor).put("trialDays", trialDays ?: JSONObject.NULL)
18
+ internal fun matches(provider: IndianaSubscriptionProvider): Boolean = when (provider) {
19
+ IndianaSubscriptionProvider.STRIPE, IndianaSubscriptionProvider.MERCHANT -> false
20
+ IndianaSubscriptionProvider.APPLE -> (offerKind == IndianaNativeOfferKind.PROMOTIONAL) == (offerId != null)
21
+ IndianaSubscriptionProvider.GOOGLE -> (offerKind == IndianaNativeOfferKind.NONE) == (offerId == null) &&
22
+ (offerId == null || offerId.matches(Regex("[a-z0-9][a-z0-9-]*")))
23
+ }
24
+ }
@@ -0,0 +1,75 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import org.json.JSONObject
4
+ import java.security.MessageDigest
5
+
6
+ /** Public immutable JSON only; the API's authenticated reply pins these exact bytes. */
7
+ internal class IndianaPaywallContent(configuration: IndianaConfiguration) {
8
+ private val relay = IndianaFunnelConfigurationRelay(configuration)
9
+ suspend fun resolve(delivery: JSONObject): JSONObject {
10
+ IndianaSchema.validate("paywallDelivery", delivery)
11
+ val content = delivery.getJSONObject("content")
12
+ val artifact = content.getJSONObject("artifact")
13
+ val digest = artifact.getString("sha256")
14
+ val path = artifact.getString("url")
15
+ if (artifact.getString("kind") != "paywall" || artifact.getInt("formatVersion") != 1) throw IndianaClientException("invalid_response")
16
+ if (path != "/api/v1/configuration/${content.getString("environmentId")}/artifacts/$digest") throw IndianaClientException("invalid_response")
17
+ val reply = relay.read(JSONObject().put("path", path))
18
+ val bytes = reply.getString("body").toByteArray(Charsets.UTF_8)
19
+ if (reply.getInt("status") != 200 || !reply.getJSONObject("headers").optString("content-type").lowercase().startsWith("application/json")) throw IndianaClientException("invalid_response")
20
+ return hydrate(delivery, bytes)
21
+ }
22
+ companion object {
23
+ private val presentationFields = listOf("amountMinor", "interval", "intervalCount", "prepaid")
24
+ fun validatesHydration(loaded: JSONObject, delivery: JSONObject): Boolean {
25
+ val protected = JSONObject(loaded.toString()).apply { remove("document"); remove("offering") }
26
+ val plans = protected.getJSONObject("capabilities").getJSONObject("plans")
27
+ val original = delivery.getJSONObject("capabilities").getJSONObject("plans")
28
+ for (id in plans.keys()) {
29
+ val plan = plans.getJSONObject(id)
30
+ for (key in presentationFields) if (original.optJSONObject(id)?.has(key) != true) plan.remove(key)
31
+ }
32
+ return IndianaSchema.equal(protected, delivery)
33
+ }
34
+ fun applyPresentation(loaded: JSONObject, latest: JSONObject) {
35
+ latest.put("document", loaded.getJSONObject("document")).put("offering", loaded.getJSONObject("offering"))
36
+ val plans = latest.getJSONObject("capabilities").getJSONObject("plans")
37
+ val hydrated = loaded.getJSONObject("capabilities").getJSONObject("plans")
38
+ for (id in plans.keys()) {
39
+ val plan = plans.getJSONObject(id); val source = hydrated.optJSONObject(id) ?: continue
40
+ if (source.optString("currency") != plan.optString("currency")) continue
41
+ for (key in presentationFields) if (source.has(key)) plan.put(key, source.get(key))
42
+ }
43
+ }
44
+ fun hydrate(delivery: JSONObject, bytes: ByteArray): JSONObject {
45
+ IndianaSchema.validate("paywallDelivery", delivery)
46
+ val content = delivery.getJSONObject("content"); val artifact = content.getJSONObject("artifact")
47
+ val digest = artifact.getString("sha256")
48
+ if (artifact.getString("kind") != "paywall" || artifact.getInt("formatVersion") != 1) throw IndianaClientException("invalid_response")
49
+ val actual = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
50
+ if (bytes.size != artifact.getInt("byteLength") || actual != digest) throw IndianaClientException("invalid_response")
51
+ val text = try { Charsets.UTF_8.newDecoder().onMalformedInput(java.nio.charset.CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString() }
52
+ catch (_: Exception) { throw IndianaClientException("invalid_response") }
53
+ val file = JSONObject(text); IndianaSchema.validate("publicPaywall", file)
54
+ if (file.getString("offeringVersionId") != content.getString("offeringVersionId")) throw IndianaClientException("invalid_response")
55
+ val document = JSONObject().put("schemaVersion", 3)
56
+ for (key in listOf("kind", "name", "presentation", "products", "offeringVersionId", "screen", "buttons")) document.put(key, file.get(key))
57
+ val result = JSONObject(delivery.toString()).put("document", document).put("offering", JSONObject().put("id", file.getString("offeringVersionId")))
58
+ val plans = result.getJSONObject("capabilities").getJSONObject("plans")
59
+ val catalog = file.getJSONObject("catalog")
60
+ for (id in catalog.keys()) {
61
+ val plan = plans.optJSONObject(id) ?: continue
62
+ val product = catalog.getJSONObject(id)
63
+ val prices = product.getJSONObject("channels").optJSONObject("stripe")?.optJSONArray("prices") ?: continue
64
+ for (index in 0 until prices.length()) {
65
+ val price = prices.getJSONObject(index)
66
+ if (price.getString("currency") != plan.optString("currency")) continue
67
+ plan.put("amountMinor", price.get("amountMinor")).put("interval", product.get("interval")).put("intervalCount", product.get("intervalCount"))
68
+ if (product.has("prepaid")) plan.put("prepaid", product.getJSONArray("prepaid"))
69
+ }
70
+ }
71
+ IndianaSchema.validate("paywallResolution", result)
72
+ return result
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,144 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import kotlinx.coroutines.sync.Mutex
4
+ import kotlinx.coroutines.sync.withLock
5
+ import org.json.JSONObject
6
+ import org.json.JSONArray
7
+ import java.net.URI
8
+ import java.util.UUID
9
+ import java.util.concurrent.atomic.AtomicReference
10
+
11
+ /** Independent Paywall session; credentials and commercial authority stay in native/API code. */
12
+ class IndianaPaywallSession internal constructor(
13
+ private val configuration: IndianaConfiguration, private val http: IndianaHTTP, customerToken: String,
14
+ paywallId: UUID?, versionId: UUID?, private val provider: IndianaSubscriptionProvider,
15
+ private val currency: String, private val locale: String,
16
+ private val content: suspend (JSONObject) -> JSONObject = IndianaPaywallContent(configuration)::resolve
17
+ ) {
18
+ private val token = AtomicReference<String?>(customerToken)
19
+ private val view = AtomicReference<JSONObject?>(null)
20
+ private val quoted = AtomicReference<JSONObject?>(null)
21
+ private val purchased = AtomicReference<JSONObject?>(null)
22
+ private val mutex = Mutex()
23
+ private var pendingPreview: String? = null
24
+ @Volatile private var invalidation: (() -> Unit)? = null
25
+ private val selector: JSONObject
26
+ init {
27
+ require((paywallId == null) != (versionId == null) && locale.length in 1..35 && currency.matches(Regex("[A-Z]{3}")))
28
+ selector = if (paywallId != null) JSONObject().put("type", "paywall").put("paywallId", paywallId.toString())
29
+ else JSONObject().put("type", "version").put("versionId", versionId.toString())
30
+ }
31
+ val current: JSONObject? get() = view.get()?.let { JSONObject(it.toString()) }
32
+ fun close() { token.set(null); view.set(null); quoted.set(null); purchased.set(null); invalidation?.invoke(); invalidation = null }
33
+ internal fun onInvalidation(callback: () -> Unit) { invalidation = callback; if (token.get() == null) callback() }
34
+ internal fun hostConfiguration() = configuration
35
+ internal fun hostInit() = JSONObject().put("event", "init").put("surface", "paywall")
36
+ .put("scope", JSONObject().put("projectId", configuration.scope.projectId.toString()).put("environment", configuration.scope.environment.value))
37
+ .put("apiOrigin", configuration.apiOrigin)
38
+ private suspend fun send(name: String, path: String, body: JSONObject): JSONObject {
39
+ val original = token.get() ?: throw IndianaClientException("identity_changed")
40
+ IndianaSchema.validate(name + "Request", body)
41
+ val result = try { http.send(path, "POST", original, body) } catch (e: Exception) {
42
+ if (token.get() != original) throw IndianaClientException("identity_changed"); throw e
43
+ }
44
+ if (token.get() != original) throw IndianaClientException("identity_changed")
45
+ IndianaSchema.validate(name + "Response", result)
46
+ if (safe { scope(result.getJSONObject("scope")) } != configuration.scope) throw IndianaClientException("scope_mismatch")
47
+ if (name == "resolvePaywall") {
48
+ result.put("paywall", content(result.getJSONObject("paywall")))
49
+ if (token.get() != original) throw IndianaClientException("identity_changed")
50
+ }
51
+ return result
52
+ }
53
+ suspend fun refresh(): JSONObject = hostCommand(JSONObject().put("operation", "resolve").put("input", JSONObject()))
54
+ internal suspend fun hostCommand(payload: JSONObject): JSONObject = mutex.withLock {
55
+ if (token.get() == null || payload.keys().asSequence().toSet() != setOf("operation", "input")) throw IndianaClientException("invalid_command")
56
+ val input = payload.getJSONObject("input")
57
+ when (val action = payload.getString("operation")) {
58
+ "resolve" -> {
59
+ if (input.length() != 0) throw IndianaClientException("invalid_command")
60
+ val prior = view.get()
61
+ val selected = prior?.let { JSONObject().put("type", "version").put("versionId", it.getString("id")) } ?: selector
62
+ val result = send("resolvePaywall", "paywalls/resolve", JSONObject().put("selector", selected)
63
+ .put("commerce", JSONObject().put("provider", provider.value).put("currency", currency)).put("locale", locale)).getJSONObject("paywall")
64
+ val key = if (selector.getString("type") == "version") "id" else "paywallId"
65
+ if (result.getString(key) != selector.getString(if (key == "id") "versionId" else key) ||
66
+ prior != null && (result.getString("id") != prior.getString("id") || result.getJSONObject("offering").getString("id") != prior.getJSONObject("offering").getString("id") || result.getJSONObject("content").getJSONObject("artifact").getString("sha256") != prior.getJSONObject("content").getJSONObject("artifact").getString("sha256")) ||
67
+ result.getJSONObject("document").getString("offeringVersionId") != result.getJSONObject("offering").getString("id")) throw IndianaClientException("invalid_response")
68
+ view.set(result); JSONObject(result.toString())
69
+ }
70
+ "preview" -> {
71
+ if (!setOf("actionId", "productVersionId", "requestId", "offerId", "nativeOffer", "featureQuantities").containsAll(input.keys().asSequence().toSet())) throw IndianaClientException("invalid_command")
72
+ val product = input.getString("productVersionId")
73
+ val policy = view.get()?.getJSONObject("document")?.getJSONObject("buttons")?.optJSONObject(input.getString("actionId")) ?: throw IndianaClientException("invalid_command")
74
+ if (policy.optString("type") != "checkout") throw IndianaClientException("invalid_command")
75
+ val selection = policy.getJSONObject("product")
76
+ val allowed = if (selection.getString("type") == "fixed") selection.getString("productVersionId") == product
77
+ else selection.getJSONArray("allowedProductVersionIds").let { ids -> (0 until ids.length()).any { ids.getString(it) == product } }
78
+ if (!allowed) throw IndianaClientException("invalid_command")
79
+ val fingerprint = canonical(payload)
80
+ if (pendingPreview != null && pendingPreview != fingerprint) {
81
+ val status = purchased.get()?.optString("status")
82
+ if (status == null || status == "pending") throw IndianaClientException("command_pending")
83
+ quoted.set(null); purchased.set(null)
84
+ }
85
+ if (pendingPreview == fingerprint) quoted.get()?.let { return@withLock JSONObject(it.toString()) }
86
+ val selectedPlan = JSONObject().put("productVersionId", product)
87
+ if (input.has("featureQuantities")) selectedPlan.put("featureQuantities", input.getJSONObject("featureQuantities"))
88
+ val body = JSONObject().put("requestId", input.getString("requestId"))
89
+ .put("plans", JSONArray().put(selectedPlan)).put("provider", provider.value).put("currency", currency)
90
+ for (key in listOf("offerId", "nativeOffer")) if (input.has(key)) body.put(key, input.get(key))
91
+ IndianaSchema.validate("previewAttachRequest", body)
92
+ pendingPreview = fingerprint
93
+ val result = send("previewAttach", "subscriptions/preview-attach", body).getJSONObject("quote")
94
+ if (result.getString("provider") != provider.value || result.getString("currency") != currency || result.getJSONArray("lines").length() != 1 || result.getJSONArray("lines").getJSONObject(0).getString("productVersionId") != product) throw IndianaClientException("invalid_response")
95
+ quoted.set(result); purchased.set(null); JSONObject(result.toString())
96
+ }
97
+ "confirm", "cancel", "read" -> {
98
+ if (input.length() != 0) throw IndianaClientException("invalid_command")
99
+ val id = quoted.get()?.getString("operationId") ?: throw IndianaClientException("invalid_command")
100
+ val body = JSONObject().put("operationId", id)
101
+ if (action != "read") body.put("intent", action)
102
+ val result = send(if (action == "read") "readSubscriptionOperation" else "attach",
103
+ if (action == "read") "subscriptions/operations/read" else "subscriptions/attach", body).getJSONObject("operation")
104
+ if (result.getString("operationId") != id || result.getString("currency") != currency) throw IndianaClientException("invalid_response")
105
+ purchased.get()?.takeIf { it.getString("status") == "complete" }?.let { return@withLock JSONObject(it.toString()) }
106
+ purchased.set(result)
107
+ if (result.getString("status") !in setOf("pending", "complete")) { pendingPreview = null; quoted.set(null) }
108
+ JSONObject(result.toString())
109
+ }
110
+ else -> throw IndianaClientException("invalid_command")
111
+ }
112
+ }
113
+ internal fun trustedPurchase(value: JSONObject): JSONObject {
114
+ val saved = purchased.get() ?: throw IndianaClientException("invalid_command")
115
+ if (token.get() == null || saved.optString("status") != "pending" || canonical(value) != canonical(saved)) throw IndianaClientException("invalid_command")
116
+ return JSONObject(saved.toString())
117
+ }
118
+ internal fun trustedCheckout(value: String): URI {
119
+ val saved = purchased.get() ?: throw IndianaClientException("invalid_command")
120
+ val url = URI(value)
121
+ if (token.get() == null || saved.optString("status") != "pending" || saved.optString("checkoutUrl") != value || url.scheme != "https" || url.userInfo != null) throw IndianaClientException("invalid_command")
122
+ return url
123
+ }
124
+ internal fun trustedAction(value: String, type: String): String {
125
+ val buttons = view.get()?.getJSONObject("document")?.getJSONObject("buttons") ?: throw IndianaClientException("invalid_command")
126
+ if (token.get() == null || !buttons.keys().asSequence().any { key ->
127
+ val policy = buttons.getJSONObject(key); val action = policy.optJSONObject("action")
128
+ policy.optString("type") == "action" && action?.optString("type") == type && action.optString(if (type == "event") "event" else "url") == value
129
+ }) throw IndianaClientException("invalid_command")
130
+ return value
131
+ }
132
+ internal fun completedOperation(): JSONObject {
133
+ val saved = purchased.get() ?: throw IndianaClientException("invalid_command")
134
+ if (token.get() == null || saved.optString("status") != "complete") throw IndianaClientException("invalid_command")
135
+ return JSONObject(saved.toString())
136
+ }
137
+ private fun canonical(value: Any?): String = when(value) {
138
+ is JSONObject -> value.keys().asSequence().sorted().joinToString(",", "{", "}") { JSONObject.quote(it) + ":" + canonical(value.get(it)) }
139
+ is JSONArray -> (0 until value.length()).joinToString(",", "[", "]") { canonical(value.get(it)) }
140
+ is String -> JSONObject.quote(value)
141
+ null, JSONObject.NULL -> "null"
142
+ else -> value.toString()
143
+ }
144
+ }
@@ -0,0 +1,74 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.graphics.Color
6
+ import android.net.Uri
7
+ import android.widget.FrameLayout
8
+ import kotlinx.coroutines.CoroutineScope
9
+ import kotlinx.coroutines.Dispatchers
10
+ import kotlinx.coroutines.SupervisorJob
11
+ import kotlinx.coroutines.cancel
12
+ import org.json.JSONObject
13
+ import java.net.URI
14
+
15
+ /** Independent Paywall using exactly the same packaged renderer as Funnel. */
16
+ class IndianaPaywallView(private val activity: Activity, val session: IndianaPaywallSession): FrameLayout(activity) {
17
+ var onPurchase: (suspend (JSONObject) -> Unit)? = null
18
+ var onEvent: (suspend (String) -> Unit)? = null
19
+ var onOpenUrl: (suspend (URI) -> Unit)? = null
20
+ var onComplete: ((JSONObject) -> Unit)? = null
21
+ var onDismiss: (() -> Unit)? = null
22
+ var onError: ((Exception) -> Unit)? = null
23
+ private val work = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
24
+ private var stopped = false; private var dismissed = false
25
+ private val completions = mutableSetOf<String>()
26
+ private val host = IndianaBundledWebView(activity, "Paywall", work, ::dispatch) { onError?.invoke(it) }
27
+ init {
28
+ setBackgroundColor(Color.TRANSPARENT)
29
+ addView(host.view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
30
+ session.onInvalidation { post { destroy() } }
31
+ host.start()
32
+ }
33
+ private suspend fun dispatch(method: String, raw: Any?): Any? = when (method) {
34
+ "ready" -> { host.send(session.hostInit()); null }
35
+ "paywall" -> session.hostCommand(raw as? JSONObject ?: throw IndianaClientException("invalid_command"))
36
+ "purchase" -> {
37
+ val value = (raw as? JSONObject)?.optJSONObject("operation") ?: throw IndianaClientException("invalid_command")
38
+ val trusted = session.trustedPurchase(value)
39
+ val callback = onPurchase ?: throw IndianaClientException("unsupported_capability")
40
+ callback(trusted); null
41
+ }
42
+ "checkout" -> {
43
+ val value = (raw as? JSONObject)?.optString("url") ?: throw IndianaClientException("invalid_command")
44
+ open(session.trustedCheckout(value)); null
45
+ }
46
+ "open" -> {
47
+ val value = (raw as? JSONObject)?.optString("url") ?: throw IndianaClientException("invalid_command")
48
+ val url = URI(session.trustedAction(value, "open_url"))
49
+ if (url.scheme != "https") throw IndianaClientException("invalid_command")
50
+ val callback = onOpenUrl
51
+ if (callback != null) callback(url) else open(url)
52
+ null
53
+ }
54
+ "event" -> {
55
+ val value = (raw as? JSONObject)?.optString("event") ?: throw IndianaClientException("invalid_command")
56
+ val trusted = session.trustedAction(value, "event")
57
+ val callback = onEvent ?: throw IndianaClientException("unsupported_capability")
58
+ callback(trusted); null
59
+ }
60
+ "complete" -> { val result = session.completedOperation(); if (completions.add(result.getString("operationId"))) { onComplete?.invoke(result) }; null }
61
+ "dismiss" -> { close(); null }
62
+ "error" -> { onError?.invoke(IndianaClientException("renderer_failed")); null }
63
+ else -> throw IndianaClientException("invalid_command")
64
+ }
65
+ private fun open(url: URI) { activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url.toString())).addCategory(Intent.CATEGORY_BROWSABLE)) }
66
+ fun refresh() { host.event("refresh") }
67
+ override fun onWindowFocusChanged(hasWindowFocus: Boolean) { super.onWindowFocusChanged(hasWindowFocus); if (hasWindowFocus) refresh() }
68
+ fun destroy() {
69
+ if (stopped) return
70
+ stopped = true; host.destroy(); work.cancel(); removeView(host.view); session.close()
71
+ if (!dismissed) { dismissed = true; onDismiss?.invoke() }
72
+ }
73
+ fun close() { destroy() }
74
+ }
@@ -0,0 +1,28 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import org.json.JSONObject
4
+ import java.util.UUID
5
+ import java.text.SimpleDateFormat
6
+ import java.util.Locale
7
+ import java.util.TimeZone
8
+
9
+ data class IndianaScope(val projectId: UUID, val environment: IndianaEnvironment)
10
+ class IndianaClientException(val code: String): Exception(code)
11
+ internal fun uuid(value: String): UUID { require(IndianaProtocol.uuidPattern.matches(value));val id=UUID.fromString(value);require(id.toString()==value.lowercase(Locale.ROOT));return id }
12
+ internal fun text(raw: JSONObject,key: String): String {val value=raw.get(key);require(value is String);return value}
13
+ internal fun keys(raw: JSONObject, expected: Set<String>) { require(raw.keys().asSequence().toSet()==expected) }
14
+ internal fun fields(raw: JSONObject, allowed: Set<String>, required: Set<String>) { val actual=raw.keys().asSequence().toSet(); require(allowed.containsAll(actual) && actual.containsAll(required)) }
15
+ internal fun scope(raw: JSONObject): IndianaScope = safe {
16
+ keys(raw,IndianaProtocol.scopeKeys);IndianaScope(uuid(text(raw,"projectId")),IndianaEnvironment.entries.single {it.value==text(raw,"environment")})
17
+ }
18
+ internal fun utcMillis(value: String): Long = safe {
19
+ require(IndianaProtocol.utcPattern.matches(value))
20
+ val match=Regex("^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})(?:\\.(\\d+))?(Z|[+-]\\d{2}:\\d{2})$").matchEntire(value) ?: throw IllegalArgumentException()
21
+ val formatter=SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss",Locale.ROOT).apply {isLenient=false;timeZone=TimeZone.getTimeZone("UTC")}
22
+ val date=formatter.parse(match.groupValues[1]) ?: throw IllegalArgumentException()
23
+ val offset = match.groupValues[3]
24
+ val offsetMillis = if(offset == "Z") 0L else ((offset.substring(1,3).toLong()*60 + offset.substring(4,6).toLong())*60_000) * if(offset[0] == '+') 1 else -1
25
+ date.time+match.groupValues[2].padEnd(3,'0').take(3).toLong()-offsetMillis
26
+ }
27
+ internal inline fun <T> safe(block:()->T): T = try{block()}catch(e:IndianaClientException){throw e}catch(_:Exception){throw IndianaClientException("invalid_response")}
28
+