@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,526 @@
|
|
|
1
|
+
package ai.tinkerland.indiana
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
import java.util.UUID
|
|
7
|
+
import java.time.Instant
|
|
8
|
+
import java.lang.ref.WeakReference
|
|
9
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
10
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
11
|
+
import org.json.JSONArray
|
|
12
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
13
|
+
import kotlinx.coroutines.CoroutineScope
|
|
14
|
+
import kotlinx.coroutines.SupervisorJob
|
|
15
|
+
import kotlinx.coroutines.Dispatchers
|
|
16
|
+
import kotlinx.coroutines.launch
|
|
17
|
+
import kotlinx.coroutines.cancel
|
|
18
|
+
import kotlinx.coroutines.sync.Mutex
|
|
19
|
+
import kotlinx.coroutines.sync.withLock
|
|
20
|
+
|
|
21
|
+
enum class IndianaSubscriptionProvider(val value: String) { STRIPE("stripe"), APPLE("apple"), GOOGLE("google") }
|
|
22
|
+
data class IndianaSubscriptionPlan(val productVersionId: UUID, val seatQuantities: Map<String, Int> = emptyMap(), val featureQuantities: Map<UUID, Int> = emptyMap())
|
|
23
|
+
|
|
24
|
+
interface IndianaGoogleStore: AutoCloseable {
|
|
25
|
+
suspend fun connect()
|
|
26
|
+
suspend fun quote(productId: String, basePlanId: String?, offerId: String?): GoogleQuote
|
|
27
|
+
suspend fun purchase(activity: Activity, productId: String, basePlanId: String?, offerId: String?, obfuscatedAccountId: String, obfuscatedProfileId: String, terms: GoogleStoreTerms)
|
|
28
|
+
suspend fun scanPurchases(membership: Boolean): List<GooglePurchaseEvidence>
|
|
29
|
+
}
|
|
30
|
+
/** Current customer session and independent subscription operations. */
|
|
31
|
+
class IndianaClient internal constructor(private val configuration: IndianaConfiguration, http: IndianaHTTP, private val funnelStorage: ((String) -> IndianaFunnelCommandStorage)? = null, private val publicPaywallContent: (suspend (JSONObject) -> JSONObject)? = null, private val pushStorage: (() -> IndianaPushStorage)? = null, sessionNow: () -> Instant = Instant::now): AutoCloseable {
|
|
32
|
+
constructor(context: Context, configuration: IndianaConfiguration): this(configuration, IndianaURLTransport(configuration), { token -> IndianaFunnelCommandKeystore(context.applicationContext,configuration,token) }, null, { IndianaPushKeystore(context.applicationContext, configuration.scope) })
|
|
33
|
+
private val http = IndianaSessionTransport(http, configuration.scope, sessionNow)
|
|
34
|
+
private data class Identity(val token: String?, val epoch: UUID)
|
|
35
|
+
private val identity = AtomicReference(Identity(null, UUID.randomUUID()))
|
|
36
|
+
private val identityLock = Any()
|
|
37
|
+
private var pushIdentity: IndianaPushIdentity? = null
|
|
38
|
+
private var pushBindingSequence = 0L
|
|
39
|
+
private val pushClientID = UUID.randomUUID()
|
|
40
|
+
private var pushObservationID = UUID.randomUUID()
|
|
41
|
+
private val pushScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
42
|
+
private val pushSyncLock = Mutex()
|
|
43
|
+
@Volatile var pushSynchronizationError: String? = null
|
|
44
|
+
private set
|
|
45
|
+
private val funnels = mutableListOf<WeakReference<IndianaFunnelSession>>()
|
|
46
|
+
private val paywalls = mutableListOf<WeakReference<IndianaPaywallSession>>()
|
|
47
|
+
private val contents = mutableListOf<WeakReference<IndianaContentSession>>()
|
|
48
|
+
private val featuresClients = mutableListOf<WeakReference<IndianaFeatureClient>>()
|
|
49
|
+
private val analyticsEvents = mutableListOf<JSONObject>()
|
|
50
|
+
private val analyticsFlushLock = Mutex()
|
|
51
|
+
private var analyticsTimer: kotlinx.coroutines.Job? = null
|
|
52
|
+
private val closed = AtomicBoolean(false)
|
|
53
|
+
private val storeRestorePending = java.util.concurrent.ConcurrentHashMap<String, Pair<UUID, String>>()
|
|
54
|
+
private val recoveringNative = AtomicBoolean(false)
|
|
55
|
+
private val nativeReceiptPending = java.util.concurrent.ConcurrentHashMap<UUID, Pair<UUID, String>>()
|
|
56
|
+
fun identify(customerToken: String) = synchronized(identityLock) {
|
|
57
|
+
if (!customerToken.matches(Regex("gg_ct_[A-Za-z0-9_-]{32,128}"))) throw IndianaClientException("unauthenticated")
|
|
58
|
+
installIdentity(customerToken, null)
|
|
59
|
+
}
|
|
60
|
+
private fun installIdentity(customerToken: String, session: IndianaCustomerSession?) {
|
|
61
|
+
checkOpen(); pushIdentity?.let { pushBindingSequence = it.reserve(); pushSynchronizationError = null }
|
|
62
|
+
analyticsTimer?.cancel(); analyticsTimer = null; analyticsEvents.clear()
|
|
63
|
+
val next = Identity(customerToken, UUID.randomUUID())
|
|
64
|
+
val invalidated = { invalidateSession(next.epoch) }
|
|
65
|
+
if (session == null) http.identify(customerToken, invalidated) else http.identify(session, invalidated)
|
|
66
|
+
pushObservationID = UUID.randomUUID(); nativeReceiptPending.clear(); storeRestorePending.clear(); identity.set(next); funnels.forEach { it.get()?.reset() }; funnels.clear(); paywalls.forEach { it.get()?.close() }; paywalls.clear()
|
|
67
|
+
contents.forEach { it.get()?.close() }; contents.clear()
|
|
68
|
+
featuresClients.forEach { it.get()?.close() }; featuresClients.clear(); schedulePush()
|
|
69
|
+
}
|
|
70
|
+
fun identify(session: IndianaCustomerSession) = synchronized(identityLock) { session.validate(); installIdentity(session.accessToken, session) }
|
|
71
|
+
suspend fun refreshSession(): IndianaCustomerSession = http.refresh()
|
|
72
|
+
/** A host-owned session supplies Access Tokens; its Refresh Token never crosses the bridge. */
|
|
73
|
+
fun setAccessTokenProvider(provider: (suspend () -> String)?) { http.setAccessTokenProvider(provider) }
|
|
74
|
+
@JvmOverloads fun funnel(runToken: String, commandStorage: IndianaFunnelCommandStorage? = null): IndianaFunnelSession = synchronized(identityLock) {
|
|
75
|
+
checkOpen(); val session = IndianaFunnelSession(configuration, http, runToken, commandStorage ?: funnelStorage?.invoke(runToken))
|
|
76
|
+
identity.get().token?.let { session.identify(it) }; funnels.removeAll { it.get() == null }; funnels.add(WeakReference(session)); session
|
|
77
|
+
}
|
|
78
|
+
@JvmOverloads fun paywall(paywallId: UUID? = null, paywallVersionId: UUID? = null, provider: IndianaSubscriptionProvider, currency: String, locale: String = "en"): IndianaPaywallSession = synchronized(identityLock) {
|
|
79
|
+
checkOpen(); val token = identity.get().token ?: throw IndianaClientException("unauthenticated")
|
|
80
|
+
val session = IndianaPaywallSession(configuration, http, token, paywallId, paywallVersionId, provider, currency, locale, publicPaywallContent ?: IndianaPaywallContent(configuration)::resolve)
|
|
81
|
+
paywalls.removeAll { it.get() == null }; paywalls.add(WeakReference(session)); session
|
|
82
|
+
}
|
|
83
|
+
private fun clearIdentity() {
|
|
84
|
+
analyticsTimer?.cancel(); analyticsTimer = null; analyticsEvents.clear()
|
|
85
|
+
try { pushIdentity?.reserve(detach = true); pushSynchronizationError = null } catch (_: Exception) { pushSynchronizationError = "protected_storage_unavailable" }
|
|
86
|
+
pushObservationID = UUID.randomUUID(); pushBindingSequence = 0; nativeReceiptPending.clear(); storeRestorePending.clear(); identity.set(Identity(null, UUID.randomUUID())); funnels.forEach { it.get()?.reset() }; funnels.clear(); paywalls.forEach { it.get()?.close() }; paywalls.clear()
|
|
87
|
+
contents.forEach { it.get()?.close() }; contents.clear()
|
|
88
|
+
featuresClients.forEach { it.get()?.close() }; featuresClients.clear(); schedulePush()
|
|
89
|
+
}
|
|
90
|
+
private fun invalidateSession(epoch: UUID) = synchronized(identityLock) {
|
|
91
|
+
if (identity.get().epoch == epoch) clearIdentity()
|
|
92
|
+
}
|
|
93
|
+
fun reset() = synchronized(identityLock) {
|
|
94
|
+
http.identify(null)
|
|
95
|
+
clearIdentity()
|
|
96
|
+
}
|
|
97
|
+
/** Relay attaches the server API key on your backend. No run credential is needed, even after run expiry. */
|
|
98
|
+
suspend fun recoverNativeOrders(context: Context, relay: suspend (JSONObject) -> JSONObject): List<JSONObject> = recoverOrders(IndianaNativePendingKeystore(context, configuration.scope), GoogleStorefront(context) { }, relay)
|
|
99
|
+
internal suspend fun recoverOrders(storage: IndianaNativePendingStorage, store: IndianaGoogleStore, relay: suspend (JSONObject) -> JSONObject): List<JSONObject> {
|
|
100
|
+
checkOpen(); val actor = identity.get(); val token = actor.token ?: throw IndianaClientException("unauthenticated")
|
|
101
|
+
if (configuration.scope.environment != IndianaEnvironment.SANDBOX || !recoveringNative.compareAndSet(false, true)) throw IndianaClientException("command_pending")
|
|
102
|
+
try {
|
|
103
|
+
store.connect(); val records = storage.orders()
|
|
104
|
+
val purchases = store.scanPurchases(false) + store.scanPurchases(true)
|
|
105
|
+
val responses = mutableListOf<JSONObject>()
|
|
106
|
+
for (record in records) {
|
|
107
|
+
checkOpen(); if (identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
|
|
108
|
+
val receipt = purchases.singleOrNull { it.productId == record.productId && it.accountBinding == record.accountId && it.intentBinding == record.profileId }
|
|
109
|
+
val command = nativeReceiptPending[record.orderId] ?: run {
|
|
110
|
+
val requestId = UUID.randomUUID()
|
|
111
|
+
if (receipt != null && receipt.purchaseToken.toByteArray().size > 16384) throw IndianaClientException("invalid_evidence")
|
|
112
|
+
val evidence = receipt?.let { JSONObject().put("provider", "google").put("orderId", record.orderId.toString()).put("receiptId", record.receiptId.toString()).put("purchaseToken", it.purchaseToken) }
|
|
113
|
+
requestId to JSONObject().put("operation", "native_receipt").put("requestId", requestId.toString()).put("customerToken", token).put("orderId", record.orderId.toString()).put("receiptId", record.receiptId.toString()).put("nativeEvidence", evidence ?: JSONObject.NULL).toString()
|
|
114
|
+
}.also { nativeReceiptPending[record.orderId] = it }
|
|
115
|
+
val credential = http.access()
|
|
116
|
+
if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
117
|
+
val outgoing = JSONObject(command.second).put("customerToken", credential)
|
|
118
|
+
val result = try { relay(outgoing) } catch (error: IndianaClientException) {
|
|
119
|
+
if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
120
|
+
val status = error.code.removePrefix("request_failed_").toIntOrNull()
|
|
121
|
+
if (status != null && status < 500 && status != 425) { nativeReceiptPending.remove(record.orderId); if (status in setOf(403,404)) continue }
|
|
122
|
+
throw error
|
|
123
|
+
}
|
|
124
|
+
if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
125
|
+
if (safe { scope(result.getJSONObject("scope")) } != configuration.scope || result.optString("requestId") != command.first.toString() || result.optString("orderId") != record.orderId.toString() || result.optString("receiptId") != record.receiptId.toString() || result.optString("orderStatus") !in setOf("created", "pending", "trialing", "paid", "cancelled", "failed") || result.opt("refunded") !is Boolean) throw IndianaClientException("invalid_response")
|
|
126
|
+
result.optJSONObject("nativeCompletion")?.let { completion ->
|
|
127
|
+
if (completion.optString("orderId") != record.orderId.toString() || completion.optString("receiptId") != record.receiptId.toString() || completion.opt("membership") !is Boolean) throw IndianaClientException("invalid_response")
|
|
128
|
+
val ids = completion.getJSONArray("transactionIds"); if (ids.length() !in 1..50 || (0 until ids.length()).any { ids.opt(it) !is String || ids.getString(it).length !in 1..255 } || (0 until ids.length()).map { ids.getString(it) }.distinct().size != ids.length()) throw IndianaClientException("invalid_response")
|
|
129
|
+
if (!completion.getBoolean("membership")) storage.remove(record.orderId)
|
|
130
|
+
}
|
|
131
|
+
nativeReceiptPending.remove(record.orderId); responses.add(JSONObject(result.toString()))
|
|
132
|
+
}
|
|
133
|
+
return responses
|
|
134
|
+
} finally { store.close(); recoveringNative.set(false) }
|
|
135
|
+
}
|
|
136
|
+
/** Reinstall and device-change recovery uses the store's current transactions, never a local order journal. */
|
|
137
|
+
suspend fun restorePurchases(context: Context): List<JSONObject> = restoreStorePurchases(GoogleStorefront(context) {})
|
|
138
|
+
internal suspend fun restoreStorePurchases(store: IndianaGoogleStore): List<JSONObject> {
|
|
139
|
+
checkOpen(); val actor = identity.get(); if (actor.token == null) throw IndianaClientException("unauthenticated")
|
|
140
|
+
if (!recoveringNative.compareAndSet(false,true)) throw IndianaClientException("command_pending")
|
|
141
|
+
try {
|
|
142
|
+
store.connect(); val purchases = store.scanPurchases(false) + store.scanPurchases(true)
|
|
143
|
+
if (purchases.size > 100) throw IndianaClientException("invalid_response")
|
|
144
|
+
return purchases.mapNotNull { purchase ->
|
|
145
|
+
if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
146
|
+
val intent = purchase.intentBinding?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
|
147
|
+
// Unbound historical purchases are not automatically transferred to a signed-in app account.
|
|
148
|
+
intent?.let { submitStorePurchase(purchase,it,actor.epoch) }
|
|
149
|
+
}
|
|
150
|
+
} finally { store.close(); recoveringNative.set(false) }
|
|
151
|
+
}
|
|
152
|
+
private suspend fun submitStorePurchase(purchase: GooglePurchaseEvidence, intent: UUID, original: UUID): JSONObject {
|
|
153
|
+
if (purchase.purchaseToken.toByteArray().size > 16384) throw IndianaClientException("invalid_evidence")
|
|
154
|
+
val command = storeRestorePending[purchase.purchaseToken] ?: run {
|
|
155
|
+
val id = UUID.randomUUID()
|
|
156
|
+
id to JSONObject().put("requestId",id.toString()).put("provider","google").put("purchaseIntentId",intent.toString()).put("productId",purchase.productId).put("purchaseToken",purchase.purchaseToken).toString()
|
|
157
|
+
}.also { storeRestorePending[purchase.purchaseToken] = it }
|
|
158
|
+
val result = authenticated("subscriptions/native/restore",JSONObject(command.second))
|
|
159
|
+
if (identity.get().epoch != original || closed.get()) throw IndianaClientException("identity_changed")
|
|
160
|
+
val operation = result.optJSONObject("operation") ?: throw IndianaClientException("invalid_response")
|
|
161
|
+
if (operation.optString("requestId") != command.first.toString() || operation.optString("purchaseIntentId") != intent.toString() || runCatching { UUID.fromString(operation.getString("orderId")) }.isFailure || operation.optString("status") !in setOf("created","pending","trialing","paid","cancelled","failed") || operation.opt("refunded") !is Boolean || !operation.has("completion")) throw IndianaClientException("invalid_response")
|
|
162
|
+
operation.optJSONObject("completion")?.let { completion ->
|
|
163
|
+
val ids = completion.optJSONArray("transactionIds") ?: throw IndianaClientException("invalid_response")
|
|
164
|
+
if (completion.opt("membership") !is Boolean || ids.length() !in 1..50 || (0 until ids.length()).any { ids.opt(it) !is String || ids.getString(it).length !in 1..255 } || (0 until ids.length()).map { ids.getString(it) }.distinct().size != ids.length()) throw IndianaClientException("invalid_response")
|
|
165
|
+
}
|
|
166
|
+
storeRestorePending.remove(purchase.purchaseToken); return result
|
|
167
|
+
}
|
|
168
|
+
/** Uses the fixed server instruction and actual Play quote; the server owns acknowledgement and grants. */
|
|
169
|
+
suspend fun purchaseSubscription(activity: Activity, operationId: UUID, confirm: suspend (GoogleQuote) -> Boolean): JSONObject {
|
|
170
|
+
if (!recoveringNative.compareAndSet(false,true)) throw IndianaClientException("command_pending")
|
|
171
|
+
try { return purchaseSubscriptionInternal(activity,operationId,false,confirm) } finally { recoveringNative.set(false) }
|
|
172
|
+
}
|
|
173
|
+
/** Uses a verified source purchase token and full-price Play replacement. */
|
|
174
|
+
suspend fun switchSubscription(activity: Activity, operationId: UUID, confirm: suspend (GoogleQuote) -> Boolean): JSONObject {
|
|
175
|
+
if (!recoveringNative.compareAndSet(false,true)) throw IndianaClientException("command_pending")
|
|
176
|
+
try { return purchaseSubscriptionInternal(activity,operationId,true,confirm) } finally { recoveringNative.set(false) }
|
|
177
|
+
}
|
|
178
|
+
private suspend fun purchaseSubscriptionInternal(activity: Activity, operationId: UUID, replacing:Boolean, confirm: suspend (GoogleQuote) -> Boolean): JSONObject {
|
|
179
|
+
checkOpen(); val actor = identity.get(); if (actor.token == null) throw IndianaClientException("unauthenticated")
|
|
180
|
+
val response = if(replacing) update(operationId) else attach(operationId); val operation = response.optJSONObject("operation") ?: throw IndianaClientException("invalid_response")
|
|
181
|
+
if (operation.optString("operationId") != operationId.toString()) throw IndianaClientException("invalid_response")
|
|
182
|
+
if (operation.optString("status") in listOf("complete","effective","cancelled","failed")) return response
|
|
183
|
+
val instruction = operation.optJSONObject("nativePurchase") ?: throw IndianaClientException("invalid_response")
|
|
184
|
+
if (instruction.optString("provider") != "google" || instruction.optString("receiptId") != operationId.toString()) throw IndianaClientException("invalid_response")
|
|
185
|
+
val order = safe { UUID.fromString(text(instruction,"orderId")) }
|
|
186
|
+
if (instruction.optString("obfuscatedProfileId") != order.toString()) throw IndianaClientException("invalid_response")
|
|
187
|
+
val product = text(instruction,"productId"); val basePlan = instruction.opt("basePlanId") as? String; val offer = instruction.opt("offerId") as? String
|
|
188
|
+
var terms = GoogleStoreTerms.parse(instruction); val result = CompletableDeferred<GoogleStoreResult>()
|
|
189
|
+
val store = GoogleStorefront(activity) { result.complete(it) }
|
|
190
|
+
try {
|
|
191
|
+
store.connect()
|
|
192
|
+
val scanned=store.scanPurchases(false)+store.scanPurchases(true)
|
|
193
|
+
if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
194
|
+
val replacement=instruction.optJSONObject("replacement")
|
|
195
|
+
if (replacing != (replacement != null) || (replacement != null && replacement.optString("policy") != "google_full_price")) throw IndianaClientException("invalid_response")
|
|
196
|
+
val purchased = scanned.singleOrNull { it.intentBinding == order.toString() && it.productId == product && it.accountBinding == text(instruction,"obfuscatedAccountId") }
|
|
197
|
+
if (purchased != null) return submitStorePurchase(purchased,order,actor.epoch)
|
|
198
|
+
if (replacement != null) {
|
|
199
|
+
terms=terms.copy(replacement=googleReplacement(instruction,scanned))
|
|
200
|
+
}
|
|
201
|
+
val quote = terms.validate(store.quote(product,basePlan,offer)).copy(replacementPolicy=if(replacing) "google_full_price" else null); val accepted = confirm(quote)
|
|
202
|
+
if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
203
|
+
if (!accepted) return if(replacing) update(operationId,"cancel") else attach(operationId,"cancel")
|
|
204
|
+
store.purchase(activity,product,basePlan,offer,text(instruction,"obfuscatedAccountId"),order.toString(),terms)
|
|
205
|
+
val outcome = result.await()
|
|
206
|
+
if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
207
|
+
return when (outcome) {
|
|
208
|
+
is GoogleStoreResult.Purchased -> {
|
|
209
|
+
if (outcome.evidence.productId != product || outcome.evidence.intentBinding != order.toString()) throw IndianaClientException("invalid_response")
|
|
210
|
+
submitStorePurchase(outcome.evidence,order,actor.epoch)
|
|
211
|
+
}
|
|
212
|
+
is GoogleStoreResult.Canceled -> if(replacing) update(operationId,"cancel") else attach(operationId,"cancel")
|
|
213
|
+
is GoogleStoreResult.Failed -> throw IndianaClientException(outcome.code)
|
|
214
|
+
}
|
|
215
|
+
} finally { store.close() }
|
|
216
|
+
}
|
|
217
|
+
suspend fun features(activity: Activity, attributes: JSONObject = JSONObject()): IndianaFeatureClient = kotlinx.coroutines.withContext(Dispatchers.Main.immediate) {
|
|
218
|
+
checkOpen(); val original = identity.get()
|
|
219
|
+
if (original.token == null) throw IndianaClientException("unauthenticated")
|
|
220
|
+
val client = IndianaFeatureClient(activity, configuration, attributes) { operation, input -> customerOperation(operation, input) }
|
|
221
|
+
synchronized(identityLock) {
|
|
222
|
+
if (identity.get().epoch != original.epoch || closed.get()) { client.close(); throw IndianaClientException("identity_changed") }
|
|
223
|
+
featuresClients.removeAll { it.get() == null }; featuresClients.add(WeakReference(client))
|
|
224
|
+
}
|
|
225
|
+
client
|
|
226
|
+
}
|
|
227
|
+
/** Reuse requestId and the entire input after an unknown acceptance result. */
|
|
228
|
+
suspend fun startFunnel(input: JSONObject, commandStorage: IndianaFunnelCommandStorage? = null): IndianaFunnelSession {
|
|
229
|
+
val original = identity.get(); val token = original.token ?: throw IndianaClientException("unauthenticated")
|
|
230
|
+
val command = JSONObject(input.toString())
|
|
231
|
+
if (command.has("customer") || command.has("customerData") || command.has("customerId") || (command.has("operation") && command.optString("operation") != "start")) throw IndianaClientException("invalid_request")
|
|
232
|
+
command.put("operation", "start"); IndianaSchema.validate("command", command)
|
|
233
|
+
val result = http.send("funnels", "POST", token, command)
|
|
234
|
+
synchronized(identityLock) {
|
|
235
|
+
if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
236
|
+
IndianaSchema.validate("response", result)
|
|
237
|
+
val run = result.getJSONObject("run")
|
|
238
|
+
if (scope(result.getJSONObject("scope")) != configuration.scope || result.has("customerId") || run.getJSONObject("source").getString("id") != command.getString("funnelId") || run.getString("locale") != command.getString("locale") || !IndianaSchema.equal(run.getJSONObject("parameters"), command.getJSONObject("parameters"))) throw IndianaClientException("invalid_response")
|
|
239
|
+
return funnel(result.getString("runToken"), commandStorage)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
suspend fun track(name: String, properties: JSONObject = JSONObject(), eventId: UUID = UUID.randomUUID(), timestamp: java.util.Date = java.util.Date()) {
|
|
243
|
+
val event = JSONObject().put("eventUuid", eventId.toString()).put("timestamp", java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }.format(timestamp)).put("name", name).put("properties", JSONObject(properties.toString()))
|
|
244
|
+
val payload = JSONObject().put("events", JSONArray().put(event))
|
|
245
|
+
if (payload.toString().toByteArray().size > 60_000) throw IndianaClientException("invalid_request")
|
|
246
|
+
IndianaSchema.validate("customertrackEventsRequest", payload)
|
|
247
|
+
val full = synchronized(identityLock) {
|
|
248
|
+
checkOpen(); if (identity.get().token == null) throw IndianaClientException("unauthenticated")
|
|
249
|
+
if (analyticsEvents.size >= 1000) throw IndianaClientException("invalid_request")
|
|
250
|
+
analyticsEvents.add(event)
|
|
251
|
+
if (analyticsTimer == null) analyticsTimer = pushScope.launch { kotlinx.coroutines.delay(2_000); synchronized(identityLock) { analyticsTimer = null }; try { flush() } catch (_: Exception) { } }
|
|
252
|
+
analyticsEvents.size >= 100
|
|
253
|
+
}
|
|
254
|
+
if (full) flush()
|
|
255
|
+
}
|
|
256
|
+
suspend fun flush(): JSONObject = analyticsFlushLock.withLock {
|
|
257
|
+
val original = synchronized(identityLock) { analyticsTimer?.cancel(); analyticsTimer = null; identity.get() }
|
|
258
|
+
if (original.token == null) throw IndianaClientException("unauthenticated")
|
|
259
|
+
val total = JSONObject().put("inserted", 0).put("duplicates", 0).put("rejected", 0).put("skipped", 0)
|
|
260
|
+
var remaining = synchronized(identityLock) { analyticsEvents.size }
|
|
261
|
+
while (remaining > 0) {
|
|
262
|
+
val batch = synchronized(identityLock) {
|
|
263
|
+
if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
264
|
+
val selected = JSONArray()
|
|
265
|
+
for (event in analyticsEvents.take(minOf(remaining, 100))) {
|
|
266
|
+
val candidate = JSONArray(selected.toString()).put(event)
|
|
267
|
+
if (selected.length() > 0 && JSONObject().put("events", candidate).toString().toByteArray().size > 64_000) break
|
|
268
|
+
selected.put(event)
|
|
269
|
+
}; selected
|
|
270
|
+
}
|
|
271
|
+
val result = customerOperation(IndianaCustomerOperation.trackEvents, JSONObject().put("events", batch))
|
|
272
|
+
if (listOf("inserted", "duplicates", "rejected", "skipped").sumOf { result.getInt(it) } != batch.length()) throw IndianaClientException("invalid_response")
|
|
273
|
+
synchronized(identityLock) {
|
|
274
|
+
if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
275
|
+
repeat(batch.length()) { analyticsEvents.removeAt(0) }; remaining -= batch.length()
|
|
276
|
+
}
|
|
277
|
+
for (key in listOf("inserted", "duplicates", "rejected", "skipped")) total.put(key, total.getInt(key) + result.getInt(key))
|
|
278
|
+
}
|
|
279
|
+
total
|
|
280
|
+
}
|
|
281
|
+
suspend fun content(input: JSONObject, locale: String = "en", inputs: JSONObject = JSONObject()): IndianaContentSession {
|
|
282
|
+
val original = identity.get().epoch
|
|
283
|
+
val session = IndianaContentSession(configuration, input, locale, inputs) { request -> customerOperation(IndianaCustomerOperation.resolveContent, request) }
|
|
284
|
+
session.refresh()
|
|
285
|
+
synchronized(identityLock) {
|
|
286
|
+
if (identity.get().epoch != original || closed.get()) { session.close(); throw IndianaClientException("identity_changed") }
|
|
287
|
+
contents.removeAll { it.get() == null }; contents.add(WeakReference(session))
|
|
288
|
+
}
|
|
289
|
+
return session
|
|
290
|
+
}
|
|
291
|
+
/** Generated Customer capability boundary. Server-only operations cannot be selected. */
|
|
292
|
+
suspend fun customerOperation(operation: IndianaCustomerOperation, input: JSONObject): JSONObject {
|
|
293
|
+
checkOpen()
|
|
294
|
+
val original = identity.get()
|
|
295
|
+
val token = original.token ?: throw IndianaClientException("unauthenticated")
|
|
296
|
+
val payload = IndianaCustomerSemantics.defaults(operation.wireName, input)
|
|
297
|
+
if (payload.has("customerId") || payload.has("customerToken")) throw IndianaClientException("invalid_request")
|
|
298
|
+
IndianaCustomerSemantics.preflight(operation.wireName, payload)
|
|
299
|
+
try { IndianaSchema.validate("customer" + operation.wireName + "Request", payload) }
|
|
300
|
+
catch (_: Exception) { throw IndianaClientException("invalid_request") }
|
|
301
|
+
IndianaCustomerSemantics.request(operation.wireName, payload)
|
|
302
|
+
val response = try { http.send(operation.path, operation.method, token, if (operation.method == "GET") null else payload) }
|
|
303
|
+
catch (error: Exception) { if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed"); throw error }
|
|
304
|
+
if (identity.get().epoch != original.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
305
|
+
if (safe { scope(response.getJSONObject("scope")) } != configuration.scope) throw IndianaClientException("scope_mismatch")
|
|
306
|
+
IndianaSchema.validate("customer" + operation.wireName + "Response", response)
|
|
307
|
+
val normalized = try { IndianaSchema.customerResponse("customer" + operation.wireName + "Response", response) } catch (error: IndianaClientException) { throw error } catch (_: Exception) { throw IndianaClientException("invalid_response") }
|
|
308
|
+
IndianaCustomerSemantics.response(operation.wireName, normalized)
|
|
309
|
+
if (scope(normalized.getJSONObject("scope")) != configuration.scope) throw IndianaClientException("invalid_response")
|
|
310
|
+
return JSONObject(normalized.toString())
|
|
311
|
+
}
|
|
312
|
+
private fun checkOpen() { if (closed.get()) throw IndianaClientException("closed") }
|
|
313
|
+
private suspend fun authenticated(path: String, body: JSONObject, credential: String? = null): JSONObject {
|
|
314
|
+
if (credential == null) IndianaCustomerOperation.entries.firstOrNull { it.path == path }?.let { return customerOperation(it, body) }
|
|
315
|
+
checkOpen(); val actor = identity.get(); val token = credential ?: actor.token ?: throw IndianaClientException("unauthenticated")
|
|
316
|
+
if (actor.token == null) throw IndianaClientException("unauthenticated")
|
|
317
|
+
val response = try { http.send(path, "POST", token, body) } catch (error: Exception) { if (identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed"); throw error }
|
|
318
|
+
if (identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
|
|
319
|
+
if (safe { scope(response.getJSONObject("scope")) } != configuration.scope) throw IndianaClientException("scope_mismatch")
|
|
320
|
+
return response
|
|
321
|
+
}
|
|
322
|
+
suspend fun readHoldings(): JSONObject = authenticated("customer-holdings", JSONObject())
|
|
323
|
+
suspend fun checkEntitlement(internalFeatureId: UUID, quantity: String = "1", properties: JSONObject = JSONObject()): JSONObject {
|
|
324
|
+
val body = JSONObject().put("requirement", JSONObject().put("internal_feature_id", internalFeatureId.toString()).put("quantity", quantity).put("properties", JSONObject(properties.toString())))
|
|
325
|
+
IndianaSchema.validate("checkRequest", body)
|
|
326
|
+
val result = authenticated("entitlements/check", body)
|
|
327
|
+
IndianaSchema.validate("checkResponse", result)
|
|
328
|
+
return result
|
|
329
|
+
}
|
|
330
|
+
/** The authenticated merchant backend selects the Customer and adds its Server Key; retain idempotencyKey on retry. */
|
|
331
|
+
suspend fun trackUsage(command: JSONObject, relay: suspend (JSONObject) -> JSONObject): JSONObject {
|
|
332
|
+
checkOpen(); val actor = identity.get(); if (actor.token == null) throw IndianaClientException("unauthenticated")
|
|
333
|
+
val body = JSONObject(command.toString()); IndianaSchema.validate("usageCommand", body)
|
|
334
|
+
val result = try { relay(body) } catch (error: Exception) {
|
|
335
|
+
if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
336
|
+
throw error
|
|
337
|
+
}
|
|
338
|
+
if (identity.get().epoch != actor.epoch || closed.get()) throw IndianaClientException("identity_changed")
|
|
339
|
+
IndianaSchema.validate("usageResponse", result)
|
|
340
|
+
if (safe { scope(result.getJSONObject("scope")) } != configuration.scope) throw IndianaClientException("invalid_response")
|
|
341
|
+
return JSONObject(result.toString())
|
|
342
|
+
}
|
|
343
|
+
/** Keep requestId on retry. Channel execution follows server capabilities. */
|
|
344
|
+
suspend fun previewAttach(requestId: UUID, plans: List<IndianaSubscriptionPlan>, provider: IndianaSubscriptionProvider, currency: String, billingSubscriptionId: UUID? = null, offerId: UUID? = null, nativeOffer: IndianaNativeStoreOffer? = null): JSONObject {
|
|
345
|
+
if (plans.size !in 1..20 || plans.map { it.productVersionId }.distinct().size != plans.size || !currency.matches(Regex("[A-Z]{3}")) || plans.any { it.featureQuantities.size > 100 || it.featureQuantities.values.any { q -> q !in 0..10000 } || it.seatQuantities.size > 20 || it.seatQuantities.any { (key, quantity) -> !key.matches(Regex("[a-z][a-z0-9_]{0,63}")) || quantity !in 0..10000 } }) throw IndianaClientException("invalid_request")
|
|
346
|
+
val entries = JSONArray()
|
|
347
|
+
plans.forEach { entries.put(JSONObject().put("productVersionId", it.productVersionId.toString()).put("seatQuantities", JSONObject(it.seatQuantities)).put("featureQuantities", JSONObject(it.featureQuantities.mapKeys { entry -> entry.key.toString() }))) }
|
|
348
|
+
val body = JSONObject().put("requestId", requestId.toString()).put("plans", entries).put("provider", provider.value).put("currency", currency)
|
|
349
|
+
billingSubscriptionId?.let { body.put("billingSubscriptionId", it.toString()) }
|
|
350
|
+
if (nativeOffer != null && (!nativeOffer.matches(provider) || plans.size != 1) ||
|
|
351
|
+
offerId != null && (billingSubscriptionId != null || provider != IndianaSubscriptionProvider.STRIPE && nativeOffer == null)) throw IndianaClientException("invalid_request")
|
|
352
|
+
offerId?.let { body.put("offerId", it.toString()) }
|
|
353
|
+
nativeOffer?.let { body.put("nativeOffer", it.wire()) }
|
|
354
|
+
return authenticated("subscriptions/preview-attach", body)
|
|
355
|
+
}
|
|
356
|
+
/** Call after the customer accepts the frozen quote. */
|
|
357
|
+
suspend fun attach(operationId: UUID,intent:String="confirm"): JSONObject { if(intent !in listOf("confirm","cancel")) throw IndianaClientException("invalid_request");return authenticated("subscriptions/attach", JSONObject().put("operationId", operationId.toString()).put("intent",intent)) }
|
|
358
|
+
suspend fun previewUpdate(requestId: UUID, membershipId: UUID, productVersionId: UUID, timing: String): JSONObject {
|
|
359
|
+
if (timing !in listOf("immediate", "renewal", "store")) throw IndianaClientException("invalid_request")
|
|
360
|
+
return authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", timing).put("change", JSONObject().put("type", "plan").put("productVersionId", productVersionId.toString())))
|
|
361
|
+
}
|
|
362
|
+
suspend fun previewSeatUpdate(requestId: UUID, membershipId: UUID, licenseKey: String, quantity: Int, timing: String): JSONObject {
|
|
363
|
+
if (timing !in listOf("immediate", "renewal") || quantity !in 0..10000 || !licenseKey.matches(Regex("[a-z][a-z0-9_]{0,63}"))) throw IndianaClientException("invalid_request")
|
|
364
|
+
return authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", timing).put("change", JSONObject().put("type", "seats").put("licenseKey", licenseKey).put("quantity", quantity)))
|
|
365
|
+
}
|
|
366
|
+
/** Executes according to the held plan's billing channel; Play plans require store support. */
|
|
367
|
+
suspend fun previewQuantityUpdate(requestId: UUID, membershipId: UUID, internal_feature_id: UUID, quantity: Int, timing: String): JSONObject {
|
|
368
|
+
if (timing !in listOf("immediate", "renewal") || quantity !in 0..10000) throw IndianaClientException("invalid_request")
|
|
369
|
+
return authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", timing).put("change", JSONObject().put("type", "quantity").put("internal_feature_id", internal_feature_id.toString()).put("quantity", quantity)))
|
|
370
|
+
}
|
|
371
|
+
suspend fun previewCancellation(requestId: UUID, membershipId: UUID, timing: String): JSONObject {
|
|
372
|
+
if (timing !in listOf("immediate", "renewal")) throw IndianaClientException("invalid_request")
|
|
373
|
+
return authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", timing).put("change", JSONObject().put("type", "cancel")))
|
|
374
|
+
}
|
|
375
|
+
suspend fun previewUncancel(requestId: UUID, membershipId: UUID): JSONObject = authenticated("subscriptions/preview-update", JSONObject().put("requestId", requestId.toString()).put("membershipId", membershipId.toString()).put("timing", "immediate").put("change", JSONObject().put("type", "uncancel")))
|
|
376
|
+
suspend fun update(operationId: UUID,intent:String="confirm"): JSONObject { if(intent !in listOf("confirm","cancel")) throw IndianaClientException("invalid_request");return authenticated("subscriptions/update", JSONObject().put("operationId", operationId.toString()).put("intent",intent)) }
|
|
377
|
+
suspend fun readSubscriptionUpdate(operationId: UUID): JSONObject = authenticated("subscriptions/updates/read", JSONObject().put("operationId", operationId.toString()))
|
|
378
|
+
suspend fun readSubscriptionOperation(operationId: UUID): JSONObject = authenticated("subscriptions/operations/read", JSONObject().put("operationId", operationId.toString()))
|
|
379
|
+
suspend fun readLedger(query: JSONObject): JSONObject = customerOperation(IndianaCustomerOperation.readLedger, JSONObject().put("query", JSONObject(query.toString())))
|
|
380
|
+
suspend fun quoteBalance(internal_feature_id: UUID, quantity: String, properties: JSONObject = JSONObject()): JSONObject = authenticated("balances/quote", JSONObject().put("command", JSONObject().put("operation", "quote").put("internal_feature_id", internal_feature_id.toString()).put("quantity", quantity).put("properties", JSONObject(properties.toString()))))
|
|
381
|
+
/** Enables the Push capability of this client. The App owns OS permission prompts. */
|
|
382
|
+
fun enablePush() = synchronized(identityLock) {
|
|
383
|
+
checkOpen(); if (pushIdentity != null) return@synchronized
|
|
384
|
+
val installation = IndianaPushIdentity(pushStorage?.invoke() ?: throw IndianaClientException("invalid_configuration"))
|
|
385
|
+
if (identity.get().token != null) pushBindingSequence = installation.reserve()
|
|
386
|
+
pushIdentity = installation; schedulePush()
|
|
387
|
+
}
|
|
388
|
+
private fun schedulePush() {
|
|
389
|
+
if (pushIdentity == null || pushSynchronizationError == "protected_storage_unavailable" || closed.get()) return
|
|
390
|
+
val original = identity.get().epoch
|
|
391
|
+
pushScope.launch {
|
|
392
|
+
try { syncPush() } catch (error: Exception) {
|
|
393
|
+
synchronized(identityLock) { if (identity.get().epoch == original) pushSynchronizationError = (error as? IndianaClientException)?.code ?: "temporary_unavailable" }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
suspend fun syncPush(): JSONObject? = pushSyncLock.withLock {
|
|
398
|
+
val (installation, actor, binding) = synchronized(identityLock) {
|
|
399
|
+
checkOpen(); if (pushSynchronizationError == "protected_storage_unavailable") throw IndianaClientException("protected_storage_unavailable")
|
|
400
|
+
Triple(pushIdentity ?: throw IndianaClientException("invalid_configuration"), identity.get(), pushBindingSequence)
|
|
401
|
+
}
|
|
402
|
+
val state = installation.state()
|
|
403
|
+
var missingDetach = false
|
|
404
|
+
state.detachSeq?.let { sequence ->
|
|
405
|
+
val command = installation.command("detach", configuration.scope, sequence)
|
|
406
|
+
try {
|
|
407
|
+
val acknowledged = validatePush(http.send("push/installations/detach", "POST", "", command), state.installationId)
|
|
408
|
+
if (acknowledged.getBoolean("bound") || acknowledged.getLong("identitySeq") != sequence) throw IndianaClientException("invalid_response")
|
|
409
|
+
}
|
|
410
|
+
catch (error: IndianaClientException) { if (error.code == "request_failed_404") missingDetach = true else if (error.code != "request_failed_412") throw error }
|
|
411
|
+
if (!missingDetach) installation.clearDetach(sequence)
|
|
412
|
+
}
|
|
413
|
+
checkPushEpoch(actor.epoch)
|
|
414
|
+
if (actor.token == null && missingDetach) throw IndianaClientException("request_failed_404")
|
|
415
|
+
val response = actor.token?.let { token ->
|
|
416
|
+
val proofCredential = http.access(); checkPushEpoch(actor.epoch)
|
|
417
|
+
val command = installation.command("bind", configuration.scope, binding, proofCredential)
|
|
418
|
+
checkPushEpoch(actor.epoch)
|
|
419
|
+
validatePush(authenticated("push/installations/bind", command, proofCredential), state.installationId).also {
|
|
420
|
+
if (!it.getBoolean("bound") || it.getLong("identitySeq") != binding) throw IndianaClientException("invalid_response")
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (missingDetach && state.detachSeq != null && binding > state.detachSeq) installation.clearDetach(state.detachSeq)
|
|
424
|
+
synchronized(identityLock) { checkPushEpoch(actor.epoch); pushSynchronizationError = null }
|
|
425
|
+
response
|
|
426
|
+
}
|
|
427
|
+
suspend fun readPushInstallation(): JSONObject {
|
|
428
|
+
val (installation, actor) = synchronized(identityLock) { checkOpen(); (pushIdentity ?: throw IndianaClientException("invalid_configuration")) to identity.get() }
|
|
429
|
+
val proofCredential = http.access(); checkPushEpoch(actor.epoch)
|
|
430
|
+
val command = installation.command("read", configuration.scope, credential = proofCredential)
|
|
431
|
+
checkPushEpoch(actor.epoch)
|
|
432
|
+
return validatePush(authenticated("push/installations/read", command, proofCredential), installation.state().installationId).also { checkPushEpoch(actor.epoch) }
|
|
433
|
+
}
|
|
434
|
+
fun capturePushObservation(): IndianaPushObservationTicket = synchronized(identityLock) {
|
|
435
|
+
checkOpen(); if (pushIdentity == null) throw IndianaClientException("invalid_configuration")
|
|
436
|
+
pushObservationID = UUID.randomUUID(); IndianaPushObservationTicket(pushClientID, identity.get().epoch, pushObservationID)
|
|
437
|
+
}
|
|
438
|
+
suspend fun recoverPushCounters(): JSONObject? {
|
|
439
|
+
val (installation, actor, expected) = synchronized(identityLock) {
|
|
440
|
+
checkOpen(); pushObservationID = UUID.randomUUID()
|
|
441
|
+
Triple(pushIdentity ?: throw IndianaClientException("invalid_configuration"), identity.get(), pushBindingSequence)
|
|
442
|
+
}
|
|
443
|
+
val remote = readPushInstallation()
|
|
444
|
+
synchronized(identityLock) { checkPushEpoch(actor.epoch); pushBindingSequence = installation.recover(remote, expected) }
|
|
445
|
+
return syncPush()
|
|
446
|
+
}
|
|
447
|
+
suspend fun observePush(observation: IndianaPushObservation, ticket: IndianaPushObservationTicket): JSONObject? {
|
|
448
|
+
fun current() = ticket.client == pushClientID && ticket.identity == identity.get().epoch && ticket.observation == pushObservationID
|
|
449
|
+
if (!synchronized(identityLock) { checkOpen(); current() }) return null
|
|
450
|
+
syncPush()
|
|
451
|
+
val proofCredential = http.access(); checkPushEpoch(ticket.identity)
|
|
452
|
+
val command = synchronized(identityLock) {
|
|
453
|
+
if (!current()) return null
|
|
454
|
+
val installation = pushIdentity ?: throw IndianaClientException("invalid_configuration")
|
|
455
|
+
installation.command("address", configuration.scope, installation.reserveAddress(), proofCredential, observation)
|
|
456
|
+
}
|
|
457
|
+
checkPushEpoch(ticket.identity)
|
|
458
|
+
val response = authenticated("push/installations/address", command, proofCredential)
|
|
459
|
+
checkPushEpoch(ticket.identity)
|
|
460
|
+
return validatePush(response, UUID.fromString(command.getString("installationId")))
|
|
461
|
+
}
|
|
462
|
+
/** Requests a security notification; the challenge arrives through the native FCM callback. */
|
|
463
|
+
suspend fun requestPushAddressRecovery(observation: IndianaPushObservation, ticket: IndianaPushObservationTicket): JSONObject {
|
|
464
|
+
recoveryPushIdentity(observation, ticket)
|
|
465
|
+
syncPush()
|
|
466
|
+
val proofCredential = http.access(); checkPushEpoch(ticket.identity)
|
|
467
|
+
val command = synchronized(identityLock) {
|
|
468
|
+
val (installation, token) = recoveryPushIdentity(observation, ticket)
|
|
469
|
+
installation.command("recovery_request", configuration.scope, installation.reserveAddress(), proofCredential, observation)
|
|
470
|
+
}
|
|
471
|
+
val response = authenticated("push/installations/recovery/request", command, proofCredential)
|
|
472
|
+
return synchronized(identityLock) {
|
|
473
|
+
recoveryPushIdentity(observation, ticket)
|
|
474
|
+
safe {
|
|
475
|
+
if (scope(response.getJSONObject("scope")) != configuration.scope) throw IndianaClientException("invalid_response")
|
|
476
|
+
val recovery = response.getJSONObject("recovery")
|
|
477
|
+
keys(recovery, setOf("requested", "expiresAt"))
|
|
478
|
+
if (recovery.opt("requested") != true) throw IndianaClientException("invalid_response")
|
|
479
|
+
utcMillis(text(recovery, "expiresAt"))
|
|
480
|
+
JSONObject(recovery.toString())
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Reacquire the current FCM token under a fresh ticket before confirming a native challenge. */
|
|
485
|
+
suspend fun confirmPushAddressRecovery(challenge: String, observation: IndianaPushObservation, ticket: IndianaPushObservationTicket): JSONObject {
|
|
486
|
+
val proofCredential = http.access(); checkPushEpoch(ticket.identity)
|
|
487
|
+
val command = synchronized(identityLock) {
|
|
488
|
+
val (installation, token) = recoveryPushIdentity(observation, ticket)
|
|
489
|
+
val state = installation.state()
|
|
490
|
+
if (state.addressSeq == 0L) throw IndianaClientException("invalid_configuration")
|
|
491
|
+
installation.command("recovery_confirm", configuration.scope, state.addressSeq, proofCredential, observation, challenge)
|
|
492
|
+
}
|
|
493
|
+
val response = authenticated("push/installations/recovery/confirm", command, proofCredential)
|
|
494
|
+
return synchronized(identityLock) {
|
|
495
|
+
val (installation, _) = recoveryPushIdentity(observation, ticket)
|
|
496
|
+
if (installation.state().addressSeq != command.getLong("sequence")) throw IndianaClientException("identity_changed")
|
|
497
|
+
val state = validatePush(response, UUID.fromString(command.getString("installationId")))
|
|
498
|
+
if (!state.getBoolean("bound") || state.getLong("identitySeq") != pushBindingSequence || state.getLong("addressSeq") != command.getLong("sequence") ||
|
|
499
|
+
state.optString("connectionId") != observation.connectionId.toString() || state.optString("addressStatus") != "active") throw IndianaClientException("invalid_response")
|
|
500
|
+
state
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
private fun recoveryPushIdentity(observation: IndianaPushObservation, ticket: IndianaPushObservationTicket): Pair<IndianaPushIdentity, String> = synchronized(identityLock) {
|
|
504
|
+
checkOpen()
|
|
505
|
+
if (ticket.client != pushClientID || ticket.identity != identity.get().epoch || ticket.observation != pushObservationID) throw IndianaClientException("identity_changed")
|
|
506
|
+
val installation = pushIdentity ?: throw IndianaClientException("invalid_configuration")
|
|
507
|
+
val token = identity.get().token ?: throw IndianaClientException("unauthenticated")
|
|
508
|
+
if (observation.connectionId == null || observation.kind != IndianaPushAddressKind.FCM_TOKEN || observation.token == null ||
|
|
509
|
+
observation.permission !in setOf(IndianaPushPermission.GRANTED, IndianaPushPermission.PROVISIONAL)) throw IndianaClientException("invalid_configuration")
|
|
510
|
+
installation to token
|
|
511
|
+
}
|
|
512
|
+
suspend fun readPushPreference(): JSONObject = authenticated("messaging/preferences/read", JSONObject().put("channel", "push"))
|
|
513
|
+
suspend fun setPushPreference(allowed: Boolean, revision: Long): JSONObject {
|
|
514
|
+
if (revision !in 0 until PUSH_MAX_SEQUENCE) throw IndianaClientException("invalid_request")
|
|
515
|
+
return authenticated("messaging/preferences/set", JSONObject().put("requestId", UUID.randomUUID().toString()).put("channel", "push").put("state", if (allowed) "allowed" else "denied").put("expectedRevision", revision))
|
|
516
|
+
}
|
|
517
|
+
private fun checkPushEpoch(original: UUID) { if (closed.get() || identity.get().epoch != original) throw IndianaClientException("identity_changed") }
|
|
518
|
+
private fun validatePush(response: JSONObject, installation: UUID): JSONObject = safe {
|
|
519
|
+
if (scope(response.getJSONObject("scope")) != configuration.scope) throw IndianaClientException("invalid_response")
|
|
520
|
+
val state = response.getJSONObject("installation")
|
|
521
|
+
if (UUID.fromString(state.getString("installationId")) != installation || state.opt("bound") !is Boolean || state.getLong("identitySeq") !in 1..PUSH_MAX_SEQUENCE || state.getLong("bindingGeneration") !in 1..PUSH_MAX_SEQUENCE || state.getLong("addressSeq") !in 0..PUSH_MAX_SEQUENCE || state.getLong("addressRevision") !in 0..PUSH_MAX_SEQUENCE || state.getString("permission") !in IndianaPushPermission.entries.map { it.value } || (!state.isNull("addressStatus") && state.getString("addressStatus") !in setOf("active", "invalid"))) throw IndianaClientException("invalid_response")
|
|
522
|
+
if (!state.isNull("connectionId")) UUID.fromString(state.getString("connectionId"))
|
|
523
|
+
JSONObject(state.toString())
|
|
524
|
+
}
|
|
525
|
+
override fun close() { if (closed.compareAndSet(false, true)) { reset(); pushScope.cancel() } }
|
|
526
|
+
}
|
|
@@ -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
|
+
}
|