@tinkerlandai/indiana-react-native 0.9.1 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,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 -> 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
|
+
|
|
@@ -0,0 +1,170 @@
|
|
|
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.JSONArray
|
|
9
|
+
import org.json.JSONObject
|
|
10
|
+
import java.io.File
|
|
11
|
+
import java.security.KeyPairGenerator
|
|
12
|
+
import java.security.KeyStore
|
|
13
|
+
import java.security.MessageDigest
|
|
14
|
+
import java.security.Signature
|
|
15
|
+
import java.security.spec.ECGenParameterSpec
|
|
16
|
+
import java.text.SimpleDateFormat
|
|
17
|
+
import java.util.Date
|
|
18
|
+
import java.util.Locale
|
|
19
|
+
import java.util.TimeZone
|
|
20
|
+
import java.util.UUID
|
|
21
|
+
|
|
22
|
+
enum class IndianaPushPermission(val value: String) { UNKNOWN("unknown"), DENIED("denied"), PROVISIONAL("provisional"), GRANTED("granted") }
|
|
23
|
+
enum class IndianaPushAddressKind(val value: String) { APNS_TOKEN("apns_token"), FCM_TOKEN("fcm_token") }
|
|
24
|
+
data class IndianaPushObservation(val connectionId: UUID?, val kind: IndianaPushAddressKind?, val token: String?, val permission: IndianaPushPermission) {
|
|
25
|
+
init {
|
|
26
|
+
require((connectionId == null && kind == null && token == null) || (connectionId != null && kind != null && token != null && token.length in 16..4096 && token.all { it.code in 33..126 }))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
class IndianaPushObservationTicket internal constructor(internal val client: UUID, internal val identity: UUID, internal val observation: UUID)
|
|
30
|
+
internal const val PUSH_MAX_SEQUENCE = 9_007_199_254_740_991L
|
|
31
|
+
internal fun pushSigningBytes(fields: JSONArray): ByteArray {
|
|
32
|
+
fun quoted(value: String) = buildString {
|
|
33
|
+
append('"')
|
|
34
|
+
for (character in value) when (character) {
|
|
35
|
+
'"' -> append("\\\"")
|
|
36
|
+
'\\' -> append("\\\\")
|
|
37
|
+
else -> { require(character.code in 32..126); append(character) }
|
|
38
|
+
}
|
|
39
|
+
append('"')
|
|
40
|
+
}
|
|
41
|
+
return (0 until fields.length()).joinToString(",", "[", "]") { index ->
|
|
42
|
+
when (val value = fields.get(index)) {
|
|
43
|
+
JSONObject.NULL -> "null"
|
|
44
|
+
is String -> quoted(value)
|
|
45
|
+
is Long -> value.toString()
|
|
46
|
+
is Int -> value.toString()
|
|
47
|
+
else -> throw IndianaClientException("invalid_configuration")
|
|
48
|
+
}
|
|
49
|
+
}.toByteArray(Charsets.UTF_8)
|
|
50
|
+
}
|
|
51
|
+
internal data class IndianaPushJournal(val installationId: UUID, val publicKey: String, val identitySeq: Long = 0, val addressSeq: Long = 0, val detachSeq: Long? = null) {
|
|
52
|
+
fun validate() {
|
|
53
|
+
if (identitySeq !in 0..PUSH_MAX_SEQUENCE || addressSeq !in 0..PUSH_MAX_SEQUENCE || (detachSeq != null && detachSeq !in 1..identitySeq) || !publicKey.matches(Regex("[A-Za-z0-9_-]{80,200}"))) throw IndianaClientException("protected_storage_unavailable")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
internal interface IndianaPushStorage {
|
|
57
|
+
/** Commits before returning; no Customer credential or provider token is stored. */
|
|
58
|
+
fun transaction(update: (IndianaPushJournal) -> IndianaPushJournal): IndianaPushJournal
|
|
59
|
+
fun sign(payload: ByteArray): String
|
|
60
|
+
}
|
|
61
|
+
internal fun pushP1363(der: ByteArray): ByteArray {
|
|
62
|
+
if (der.size !in 8..72 || der[0].toInt() != 0x30 || (der[1].toInt() and 255) != der.size - 2) throw IndianaClientException("protected_storage_unavailable")
|
|
63
|
+
var offset = 2
|
|
64
|
+
val result = ByteArray(64)
|
|
65
|
+
for (part in 0..1) {
|
|
66
|
+
if (offset + 2 > der.size || der[offset++].toInt() != 2) throw IndianaClientException("protected_storage_unavailable")
|
|
67
|
+
val size = der[offset++].toInt() and 255
|
|
68
|
+
if (size !in 1..33 || offset + size > der.size || der[offset].toInt() < 0) throw IndianaClientException("protected_storage_unavailable")
|
|
69
|
+
val leading = if (size > 1 && der[offset].toInt() == 0) 1 else 0
|
|
70
|
+
if (size - leading > 32) throw IndianaClientException("protected_storage_unavailable")
|
|
71
|
+
der.copyInto(result, part * 32 + 32 - size + leading, offset + leading, offset + size); offset += size
|
|
72
|
+
}
|
|
73
|
+
if (offset != der.size) throw IndianaClientException("protected_storage_unavailable")
|
|
74
|
+
return result
|
|
75
|
+
}
|
|
76
|
+
/** Key stays in Android Keystore; the atomic journal stays in App-private no-backup storage. */
|
|
77
|
+
internal class IndianaPushKeystore(context: Context, scope: IndianaScope): IndianaPushStorage {
|
|
78
|
+
private val namespace = "${context.packageName}:${scope.projectId}:${scope.environment.value}"
|
|
79
|
+
private val hash = MessageDigest.getInstance("SHA-256").digest(namespace.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
80
|
+
private val alias = "indiana.push.v1.$hash"
|
|
81
|
+
private val file = AtomicFile(File(context.noBackupFilesDir, "indiana-push-$hash.json"))
|
|
82
|
+
companion object { private val lock = Any() }
|
|
83
|
+
private fun store(): KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
|
84
|
+
private fun base64(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
|
85
|
+
override fun transaction(update: (IndianaPushJournal) -> IndianaPushJournal): IndianaPushJournal = synchronized(lock) {
|
|
86
|
+
try {
|
|
87
|
+
val keystore = store()
|
|
88
|
+
val current = if (!file.baseFile.exists()) {
|
|
89
|
+
if (keystore.containsAlias(alias)) throw IndianaClientException("protected_storage_unavailable")
|
|
90
|
+
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY).setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")).setDigests(KeyProperties.DIGEST_SHA256).build()
|
|
91
|
+
val key = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").apply { initialize(spec) }.generateKeyPair()
|
|
92
|
+
IndianaPushJournal(UUID.randomUUID(), base64(key.public.encoded))
|
|
93
|
+
} else {
|
|
94
|
+
val bytes = file.openRead().use { input ->
|
|
95
|
+
val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(1024)
|
|
96
|
+
while (true) { val count = input.read(buffer); if (count < 0) break; if (output.size() + count > 4096) throw IndianaClientException("protected_storage_unavailable"); output.write(buffer, 0, count) }
|
|
97
|
+
output.toByteArray()
|
|
98
|
+
}
|
|
99
|
+
if (bytes.size > 4096) throw IndianaClientException("protected_storage_unavailable")
|
|
100
|
+
val json = JSONObject(bytes.toString(Charsets.UTF_8))
|
|
101
|
+
if (json.keys().asSequence().toSet() != setOf("installationId", "publicKey", "identitySeq", "addressSeq", "detachSeq")) throw IndianaClientException("protected_storage_unavailable")
|
|
102
|
+
IndianaPushJournal(UUID.fromString(json.getString("installationId")), json.getString("publicKey"), json.getLong("identitySeq"), json.getLong("addressSeq"), if (json.isNull("detachSeq")) null else json.getLong("detachSeq"))
|
|
103
|
+
}
|
|
104
|
+
current.validate()
|
|
105
|
+
if (base64(keystore.getCertificate(alias)?.publicKey?.encoded ?: throw IndianaClientException("protected_storage_unavailable")) != current.publicKey) throw IndianaClientException("protected_storage_unavailable")
|
|
106
|
+
val next = update(current); next.validate()
|
|
107
|
+
if (next.installationId != current.installationId || next.publicKey != current.publicKey || next.identitySeq < current.identitySeq || next.addressSeq < current.addressSeq) throw IndianaClientException("protected_storage_unavailable")
|
|
108
|
+
if (!file.baseFile.exists() || next != current) {
|
|
109
|
+
val data = JSONObject().put("installationId", next.installationId.toString()).put("publicKey", next.publicKey).put("identitySeq", next.identitySeq).put("addressSeq", next.addressSeq).put("detachSeq", next.detachSeq ?: JSONObject.NULL).toString().toByteArray()
|
|
110
|
+
val stream = file.startWrite()
|
|
111
|
+
try { stream.write(data); file.finishWrite(stream) } catch (error: Exception) { file.failWrite(stream); throw error }
|
|
112
|
+
}
|
|
113
|
+
next
|
|
114
|
+
} catch (_: Exception) { throw IndianaClientException("protected_storage_unavailable") }
|
|
115
|
+
}
|
|
116
|
+
override fun sign(payload: ByteArray): String = synchronized(lock) {
|
|
117
|
+
try {
|
|
118
|
+
val key = store().getKey(alias, null) as? java.security.PrivateKey ?: throw IndianaClientException("protected_storage_unavailable")
|
|
119
|
+
val signature = Signature.getInstance("SHA256withECDSA").apply { initSign(key); update(payload) }.sign()
|
|
120
|
+
base64(pushP1363(signature))
|
|
121
|
+
} catch (_: Exception) { throw IndianaClientException("protected_storage_unavailable") }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
internal class IndianaPushIdentity(val storage: IndianaPushStorage) {
|
|
125
|
+
init { state() }
|
|
126
|
+
fun state() = storage.transaction { it }
|
|
127
|
+
fun reserve(detach: Boolean = false): Long = storage.transaction { current ->
|
|
128
|
+
if (current.identitySeq >= PUSH_MAX_SEQUENCE) throw IndianaClientException("protected_storage_unavailable")
|
|
129
|
+
val next = current.identitySeq + 1
|
|
130
|
+
current.copy(identitySeq = next, detachSeq = if (detach) next else current.detachSeq)
|
|
131
|
+
}.identitySeq
|
|
132
|
+
fun reserveAddress(): Long = storage.transaction { current ->
|
|
133
|
+
if (current.addressSeq >= PUSH_MAX_SEQUENCE) throw IndianaClientException("protected_storage_unavailable")
|
|
134
|
+
current.copy(addressSeq = current.addressSeq + 1)
|
|
135
|
+
}.addressSeq
|
|
136
|
+
fun clearDetach(sequence: Long) { storage.transaction { if (it.detachSeq == sequence) it.copy(detachSeq = null) else it } }
|
|
137
|
+
fun recover(remote: JSONObject, expected: Long): Long = storage.transaction { current ->
|
|
138
|
+
if (current.identitySeq != expected) throw IndianaClientException("identity_changed")
|
|
139
|
+
val floor = maxOf(current.identitySeq, remote.getLong("identitySeq"))
|
|
140
|
+
if (floor >= PUSH_MAX_SEQUENCE) throw IndianaClientException("protected_storage_unavailable")
|
|
141
|
+
current.copy(identitySeq = floor + 1, addressSeq = maxOf(current.addressSeq, remote.getLong("addressSeq")))
|
|
142
|
+
}.identitySeq
|
|
143
|
+
fun command(operation: String, scope: IndianaScope, sequence: Long? = null, credential: String? = null, observation: IndianaPushObservation? = null, challenge: String? = null): JSONObject {
|
|
144
|
+
val state = state(); val issued = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date())
|
|
145
|
+
val fields = JSONArray().put("indiana_push_v1").put(operation).put(scope.projectId.toString()).put(scope.environment.value).put(state.installationId.toString()).put(state.publicKey).put(issued)
|
|
146
|
+
val input = JSONObject().put("installationId", state.installationId.toString()).put("publicKey", state.publicKey).put("issuedAt", issued)
|
|
147
|
+
fun digest(): String = MessageDigest.getInstance("SHA-256").digest((credential ?: throw IndianaClientException("unauthenticated")).toByteArray()).joinToString("") { "%02x".format(it) }
|
|
148
|
+
when (operation) {
|
|
149
|
+
"bind", "detach" -> {
|
|
150
|
+
require(sequence != null); fields.put(sequence).put("android"); input.put("sequence", sequence).put("platform", "android")
|
|
151
|
+
if (operation == "bind") { val digest = digest(); fields.put(digest); input.put("credentialDigest", digest) }
|
|
152
|
+
}
|
|
153
|
+
"read" -> { val digest = digest(); fields.put(digest); input.put("credentialDigest", digest) }
|
|
154
|
+
"address", "recovery_request", "recovery_confirm" -> {
|
|
155
|
+
require(sequence != null && observation != null); val digest = digest()
|
|
156
|
+
val connection = observation.connectionId?.toString() ?: JSONObject.NULL; val kind = observation.kind?.value ?: JSONObject.NULL; val token = observation.token ?: JSONObject.NULL
|
|
157
|
+
fields.put(sequence).put(digest).put(connection).put(kind).put(token).put(observation.permission.value)
|
|
158
|
+
input.put("sequence", sequence).put("credentialDigest", digest).put("connectionId", connection).put("kind", kind).put("token", token).put("permission", observation.permission.value)
|
|
159
|
+
if (operation == "recovery_confirm") {
|
|
160
|
+
if (challenge == null || !challenge.matches(Regex("[A-Za-z0-9_-]{100,3500}"))) throw IndianaClientException("invalid_configuration")
|
|
161
|
+
fields.put(challenge); input.put("challenge", challenge)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else -> throw IndianaClientException("invalid_configuration")
|
|
165
|
+
}
|
|
166
|
+
input.put("signature", storage.sign(pushSigningBytes(fields)))
|
|
167
|
+
if (operation == "detach") input.put("projectId", scope.projectId.toString()).put("environment", scope.environment.value)
|
|
168
|
+
return input
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import java.net.URI
|
|
4
|
+
|
|
5
|
+
private object BundledRenderer {
|
|
6
|
+
val url: String by lazy {
|
|
7
|
+
checkNotNull(BundledRenderer::class.java.getResourceAsStream("/renderer-data-url.txt"))
|
|
8
|
+
.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Subframes can execute only the renderer bytes shipped with this SDK. */
|
|
12
|
+
internal fun allowsFunnelRendererNavigation(offered: URI, base: URI): Boolean =
|
|
13
|
+
base.toString() == "https://indiana-sdk.invalid/" && offered.toString().substringBefore('#') == BundledRenderer.url
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.net.Uri
|
|
6
|
+
|
|
7
|
+
/** Opens Play's renewal management. Returning to the App never proves an entitlement change. */
|
|
8
|
+
object IndianaStoreSubscriptions {
|
|
9
|
+
fun manage(activity: Activity, productId: String) {
|
|
10
|
+
val url = managementURL(activity.packageName, productId)
|
|
11
|
+
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
internal fun managementURL(packageName: String, productId: String): String {
|
|
15
|
+
require(packageName.matches(Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+"))) { "invalid_app_package" }
|
|
16
|
+
require(productId.length in 1..255 && productId.matches(Regex("[a-z0-9][a-z0-9._]*"))) { "invalid_store_product" }
|
|
17
|
+
return "https://play.google.com/store/account/subscriptions?sku=$productId&package=$packageName"
|
|
18
|
+
}
|
|
19
|
+
}
|