@tinkerlandai/longplay-react-native 0.10.8 → 0.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/index.cjs +2 -2
  2. package/dist/index.d.cts +26549 -12669
  3. package/dist/index.d.ts +26549 -12669
  4. package/dist/index.js +2 -2
  5. package/native-sdk/android/assets/indiana/index.html +42 -42
  6. package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +6 -2
  7. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +9 -3
  8. package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +46 -22
  9. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +47 -21
  10. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelProcess.kt +120 -0
  11. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +57 -16
  12. package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +18 -2
  13. package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +48 -20
  14. package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +36 -44
  15. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +82 -59
  16. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +1 -1
  17. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +3 -1
  18. package/native-sdk/android/java/ai/tinkerland/longplay/Longplay.kt +3 -0
  19. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -1
  20. package/native-sdk/ios/BundledWebView.swift +5 -1
  21. package/native-sdk/ios/CustomerSession.swift +7 -3
  22. package/native-sdk/ios/FeatureClient.swift +75 -29
  23. package/native-sdk/ios/FunnelConfigurationRelay.swift +1 -0
  24. package/native-sdk/ios/FunnelProcess.swift +148 -0
  25. package/native-sdk/ios/FunnelSession.swift +37 -9
  26. package/native-sdk/ios/HTTPTransport.swift +2 -0
  27. package/native-sdk/ios/IndianaClient.swift +25 -60
  28. package/native-sdk/ios/NativeReceiptRelay.swift +3 -3
  29. package/native-sdk/ios/PaywallSession.swift +5 -4
  30. package/native-sdk/ios/SDKVersion.swift +1 -1
  31. package/native-sdk/ios/WebView/index.html +42 -42
  32. package/native-sdk/ios/funnel-wire-schemas.json +1 -1
  33. package/package.json +1 -1
  34. package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +0 -81
  35. package/native-sdk/ios/NativePendingKeychain.swift +0 -52
@@ -66,14 +66,18 @@ internal class IndianaBundledWebView(
66
66
  val message = try { JSONObject(raw) } catch (_: Exception) { return }
67
67
  if (message.keys().asSequence().toSet() != setOf("id", "method", "payload")) return
68
68
  val id = try { UUID.fromString(message.getString("id")) } catch (_: Exception) { return }
69
- if (requests.size >= 8 || !requests.add(id)) return
69
+ if (message.opt("method") !is String || requests.contains(id)) return
70
+ if (requests.size >= 8) {
71
+ send(JSONObject().put("id",id.toString()).put("error",JSONObject().put("code","NATIVE_BUSY").put("status",429))); return
72
+ }
73
+ requests.add(id)
70
74
  work.launch {
71
75
  try {
72
76
  val result = dispatch(message.getString("method"), message.opt("payload"))
73
77
  send(JSONObject().put("id", id.toString()).put("result", result ?: JSONObject.NULL))
74
78
  } catch (error: Exception) {
75
79
  if (!stopped) {
76
- val detail = JSONObject().put("code", if (error is IndianaClientException) error.code else "NATIVE_OPERATION_FAILED")
80
+ val detail = JSONObject().put("code", if (error is IndianaClientException) { if (error.code == "command_pending") "COMMAND_PENDING" else error.code } else "NATIVE_OPERATION_FAILED")
77
81
  if (error is IndianaClientException) error.code.removePrefix("request_failed_").toIntOrNull()?.let { detail.put("status", it) }
78
82
  send(JSONObject().put("id", id.toString()).put("error", detail)); onError(error)
79
83
  }
@@ -4,12 +4,17 @@ import java.time.Instant
4
4
  import java.util.UUID
5
5
  import java.util.concurrent.atomic.AtomicReference
6
6
  import kotlinx.coroutines.CompletableDeferred
7
+ import kotlinx.coroutines.currentCoroutineContext
8
+ import kotlinx.coroutines.ensureActive
7
9
  import org.json.JSONObject
10
+ private val sessionAccessPattern = Regex("gg_ct_[A-Za-z0-9_-]{43}")
11
+ private val sessionRefreshPattern = Regex("gg_rt_[A-Za-z0-9_-]{43}")
12
+ private val hostAccessPattern = Regex("gg_ct_[A-Za-z0-9_-]{32,128}")
8
13
 
9
14
  /** Server-issued credentials. This SDK keeps the pair in memory only. */
10
15
  data class IndianaCustomerSession(val customerId: UUID, val sessionId: UUID, val accessToken: String, val refreshToken: String, val expiresAt: Instant, val refreshExpiresAt: Instant) {
11
16
  internal fun validate(fresh: Boolean = false) {
12
- if (!accessToken.matches(Regex("gg_ct_[A-Za-z0-9_-]{43}")) || !refreshToken.matches(Regex("gg_rt_[A-Za-z0-9_-]{43}")) || refreshExpiresAt < expiresAt || refreshExpiresAt <= Instant.now() || (fresh && expiresAt <= Instant.now())) throw IndianaClientException("invalid_response")
17
+ if (!sessionAccessPattern.matches(accessToken) || !sessionRefreshPattern.matches(refreshToken) || refreshExpiresAt < expiresAt || refreshExpiresAt <= Instant.now() || (fresh && expiresAt <= Instant.now())) throw IndianaClientException("invalid_response")
13
18
  }
14
19
  companion object {
15
20
  @JvmStatic fun fromJSON(value: JSONObject): IndianaCustomerSession = safe { IndianaCustomerSession(UUID.fromString(value.getString("customerId")), UUID.fromString(value.getString("sessionId")), value.getString("accessToken"), value.getString("refreshToken"), Instant.parse(value.getString("expiresAt")), Instant.parse(value.getString("refreshExpiresAt"))) }.also { it.validate() }
@@ -57,12 +62,13 @@ internal class IndianaSessionTransport(private val upstream: IndianaHTTP, privat
57
62
  val pair = current.pair ?: throw IndianaClientException("unauthenticated")
58
63
  try {
59
64
  val result = upstream.send("customer-sessions/refresh", "POST", pair.refreshToken, JSONObject())
65
+ currentCoroutineContext().ensureActive()
60
66
  if (safe { scope(result.getJSONObject("scope")) } != expectedScope) throw IndianaClientException("scope_mismatch")
61
67
  val next = IndianaCustomerSession.fromJSON(result); next.validate(true)
62
68
  if (next.customerId != pair.customerId || next.sessionId != pair.sessionId) throw IndianaClientException("scope_mismatch")
63
69
  if (!state.compareAndSet(current, current.copy(pair = next))) throw IndianaClientException("identity_changed")
64
70
  flight.result.complete(next); return next
65
- } catch (cause: Exception) {
71
+ } catch (cause: Throwable) {
66
72
  val error = if (state.get().epoch != current.epoch) IndianaClientException("identity_changed") else cause
67
73
  if (error is IndianaClientException && error.code == "request_failed_401") invalidate(current)
68
74
  flight.result.completeExceptionally(error); throw error
@@ -74,7 +80,7 @@ internal class IndianaSessionTransport(private val upstream: IndianaHTTP, privat
74
80
  if (current.original == null) throw IndianaClientException("unauthenticated")
75
81
  val token = provider()
76
82
  if (state.get().epoch != current.epoch) throw IndianaClientException("identity_changed")
77
- if (!token.matches(Regex("gg_ct_[A-Za-z0-9_-]{43}"))) throw IndianaClientException("invalid_response")
83
+ if (!hostAccessPattern.matches(token)) throw IndianaClientException("invalid_response")
78
84
  return token
79
85
  }
80
86
  if (current.pair != null && current.pair.expiresAt <= now().plusSeconds(30)) refresh(false)
@@ -7,19 +7,54 @@ import kotlinx.coroutines.Dispatchers
7
7
  import kotlinx.coroutines.SupervisorJob
8
8
  import kotlinx.coroutines.cancel
9
9
  import kotlinx.coroutines.withContext
10
- import kotlinx.coroutines.withTimeout
10
+ import kotlinx.coroutines.withTimeoutOrNull
11
11
  import org.json.JSONObject
12
12
  import java.util.UUID
13
13
 
14
+ /**
15
+ * Readiness and pending calls of one evaluator host, confined to the main thread. Stopping fails every waiter with
16
+ * its cause: `identity_changed` only for an identity change or close, otherwise the host's actual failure.
17
+ */
18
+ internal class IndianaFeatureCalls {
19
+ private val readiness = CompletableDeferred<Unit>()
20
+ private val pending = mutableMapOf<UUID, CompletableDeferred<Any?>>()
21
+ var failure: IndianaClientException? = null
22
+ private set
23
+ val ready: Boolean get() = readiness.isCompleted && failure == null
24
+ fun markReady() { failure?.let { throw it }; readiness.complete(Unit) }
25
+ /** Returns false when already stopped; the first cause wins. */
26
+ fun stop(cause: IndianaClientException): Boolean {
27
+ if (failure != null) return false
28
+ failure = cause; readiness.completeExceptionally(cause)
29
+ pending.values.forEach { it.completeExceptionally(cause) }; pending.clear(); return true
30
+ }
31
+ suspend fun call(send: (UUID) -> Unit): Any? {
32
+ failure?.let { throw it }
33
+ withTimeoutOrNull(45_000) { readiness.await() } ?: throw IndianaClientException("temporary_unavailable")
34
+ failure?.let { throw it }
35
+ val id = UUID.randomUUID(); val result = CompletableDeferred<Any?>(); pending[id] = result
36
+ try {
37
+ send(id)
38
+ val settled = withTimeoutOrNull(45_000) { arrayOf(result.await()) } ?: throw IndianaClientException("temporary_unavailable")
39
+ return settled[0]
40
+ } finally { pending.remove(id) }
41
+ }
42
+ fun settle(id: UUID, result: Any?, failed: Boolean) {
43
+ val response = pending.remove(id) ?: return
44
+ if (failed) response.completeExceptionally(IndianaClientException("invalid_response")) else response.complete(result)
45
+ }
46
+ }
47
+
14
48
  /** Customer-scoped GB evaluator using the packaged official engine, without JS credentials. */
15
49
  class IndianaFeatureClient internal constructor(private val activity: Activity, private val configuration: IndianaConfiguration, attributes: JSONObject, private val operation: suspend (IndianaCustomerOperation, JSONObject) -> JSONObject) {
16
50
  private val attributes = JSONObject(attributes.toString())
17
51
  private val work = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
18
- private val readiness = CompletableDeferred<Unit>()
19
- private val pending = mutableMapOf<UUID, CompletableDeferred<Any?>>()
52
+ private val calls = IndianaFeatureCalls()
20
53
  var onError: ((Exception) -> Unit)? = null
21
- private var stopped = false
22
- private val host = IndianaBundledWebView(activity, "Longplay Feature evaluator", work, ::dispatch) { error -> if (!readiness.isCompleted) close() else onError?.invoke(error) }
54
+ private val host = IndianaBundledWebView(activity, "Longplay Feature evaluator", work, ::dispatch) { error ->
55
+ // Before readiness a host failure ends this client with its actual cause rather than an identity change.
56
+ if (!calls.ready) stop(error as? IndianaClientException ?: IndianaClientException("renderer_failed")) else onError?.invoke(error)
57
+ }
23
58
  init { host.start() }
24
59
  suspend fun evalFeature(key: String): JSONObject {
25
60
  val result = call(JSONObject().put("method", "evalFeature").put("key", key)) as? JSONObject ?: throw IndianaClientException("invalid_response")
@@ -31,34 +66,23 @@ class IndianaFeatureClient internal constructor(private val activity: Activity,
31
66
  suspend fun setAttributes(attributes: JSONObject) { call(JSONObject().put("method", "setAttributes").put("attributes", JSONObject(attributes.toString()))) }
32
67
  suspend fun refresh() { call(JSONObject().put("method", "refresh")) }
33
68
  suspend fun flush() { call(JSONObject().put("method", "flush")) }
34
- fun close() { activity.runOnUiThread {
35
- if (!stopped) {
36
- stopped = true; host.destroy()
37
- val error = IndianaClientException("identity_changed")
38
- readiness.completeExceptionally(error); pending.values.forEach { it.completeExceptionally(error) }; pending.clear(); work.cancel()
39
- }
40
- } }
69
+ fun close() { activity.runOnUiThread { stop(IndianaClientException("identity_changed")) } }
70
+ private fun stop(cause: IndianaClientException) { if (calls.stop(cause)) { host.destroy(); work.cancel() } }
41
71
  private suspend fun call(input: JSONObject): Any? = withContext(Dispatchers.Main.immediate) {
42
- if (stopped) throw IndianaClientException("identity_changed")
43
- withTimeout(45_000) { readiness.await() }
44
- if (stopped) throw IndianaClientException("identity_changed")
45
- val id = UUID.randomUUID(); val result = CompletableDeferred<Any?>(); pending[id] = result
46
- try { host.send(JSONObject().put("event", "feature_call").put("id", id.toString()).put("input", input)); withTimeout(45_000) { result.await() } }
47
- finally { pending.remove(id) }
72
+ calls.call { id -> host.send(JSONObject().put("event", "feature_call").put("id", id.toString()).put("input", input)) }
48
73
  }
49
74
  private suspend fun dispatch(method: String, raw: Any?): Any? = when (method) {
50
75
  "ready" -> { host.send(JSONObject().put("event", "init").put("surface", "features").put("scope", JSONObject().put("projectId", configuration.scope.projectId.toString()).put("environment", configuration.scope.environment.value)).put("apiOrigin", configuration.apiOrigin).put("attributes", attributes)); null }
51
76
  "featureOperation" -> {
52
77
  val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
53
78
  val name = value.getString("operation")
54
- if (stopped || name !in setOf("getFeatures", "evaluateFeatures", "trackEvents")) throw IndianaClientException("invalid_command")
79
+ if (calls.failure != null || name !in setOf("getFeatures", "evaluateFeatures", "trackEvents")) throw IndianaClientException("invalid_command")
55
80
  operation(IndianaCustomerOperation.valueOf(name), value.getJSONObject("input"))
56
81
  }
57
- "featuresReady" -> { if (stopped) throw IndianaClientException("identity_changed"); readiness.complete(Unit); null }
82
+ "featuresReady" -> { calls.markReady(); null }
58
83
  "featureResult" -> {
59
84
  val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
60
- val response = pending.remove(UUID.fromString(value.getString("id")))
61
- if (value.has("error")) response?.completeExceptionally(IndianaClientException("invalid_response")) else response?.complete(value.opt("result")); null
85
+ calls.settle(UUID.fromString(value.getString("id")), value.opt("result"), value.has("error")); null
62
86
  }
63
87
  else -> throw IndianaClientException("invalid_command")
64
88
  }
@@ -1,7 +1,11 @@
1
1
  package ai.tinkerland.indiana
2
2
 
3
3
  import kotlinx.coroutines.Dispatchers
4
- import kotlinx.coroutines.withContext
4
+ import kotlinx.coroutines.suspendCancellableCoroutine
5
+ import kotlinx.coroutines.CancellationException
6
+ import kotlin.coroutines.resume
7
+ import kotlin.coroutines.resumeWithException
8
+ import kotlin.coroutines.EmptyCoroutineContext
5
9
  import org.json.JSONObject
6
10
  import java.io.ByteArrayOutputStream
7
11
  import java.net.HttpURLConnection
@@ -18,28 +22,50 @@ internal class IndianaFunnelConfigurationRelay(private val configuration: Indian
18
22
  val request = payload as? JSONObject
19
23
  val path = request?.opt("path") as? String
20
24
  if (request == null || request.length() != 1 || path == null || !PATH.matches(path)) throw IndianaClientException("invalid_command")
21
- return withContext(Dispatchers.IO) {
25
+ return suspendCancellableCoroutine { continuation ->
22
26
  val connection = origin.resolve(path).toURL().openConnection() as HttpURLConnection
23
- try {
24
- connection.requestMethod = "GET"; connection.instanceFollowRedirects = false; connection.useCaches = path.contains("/artifacts/")
25
- connection.connectTimeout = configuration.timeoutMs; connection.readTimeout = configuration.timeoutMs
26
- val status = connection.responseCode
27
- val headers = JSONObject()
28
- for ((key, values) in connection.headerFields) {
29
- val name = key?.lowercase() ?: continue
30
- if (name in HEADERS && values.isNotEmpty()) headers.put(name, values.joinToString(", "))
27
+ // Do not run potentially blocking disconnect on the cancelling caller.
28
+ continuation.invokeOnCancellation {
29
+ Dispatchers.IO.dispatch(EmptyCoroutineContext, Runnable { connection.disconnect() })
30
+ }
31
+ Dispatchers.IO.dispatch(continuation.context, Runnable {
32
+ if (!continuation.isActive) return@Runnable
33
+ try {
34
+ connection.requestMethod = "GET"; connection.instanceFollowRedirects = false; connection.useCaches = path.contains("/artifacts/")
35
+ connection.connectTimeout = configuration.timeoutMs; connection.readTimeout = configuration.timeoutMs
36
+ val status = connection.responseCode
37
+ val headers = JSONObject()
38
+ for ((key, values) in connection.headerFields) {
39
+ val name = key?.lowercase() ?: continue
40
+ if (name in HEADERS && values.isNotEmpty()) headers.put(name, values.joinToString(", "))
41
+ }
42
+ // The shell reports other statuses (including 429 Retry-After) by status alone.
43
+ val body = if (status in 200..299) {
44
+ if ((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0) > MAX_BYTES) throw IndianaClientException("invalid_response")
45
+ val output = ByteArrayOutputStream()
46
+ connection.inputStream.use { input ->
47
+ val buffer = ByteArray(8192)
48
+ while (true) {
49
+ if (!continuation.isActive) throw CancellationException()
50
+ val size = input.read(buffer)
51
+ if (size < 0) break
52
+ if (output.size() + size > MAX_BYTES) throw IndianaClientException("invalid_response")
53
+ output.write(buffer, 0, size)
54
+ }
55
+ }
56
+ try { Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(output.toByteArray())).toString() }
57
+ catch (_: Exception) { throw IndianaClientException("invalid_response") }
58
+ } else ""
59
+ continuation.resume(JSONObject().put("status", status).put("headers", headers).put("body", body))
60
+ } catch (error: Throwable) {
61
+ continuation.resumeWithException(when (error) {
62
+ is IndianaClientException, is CancellationException -> error
63
+ is Exception -> IndianaClientException("temporary_unavailable")
64
+ else -> error
65
+ })
31
66
  }
32
- // The shell reports other statuses (including 429 Retry-After) by status alone.
33
- val body = if (status in 200..299) {
34
- if ((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0) > MAX_BYTES) throw IndianaClientException("invalid_response")
35
- val output = ByteArrayOutputStream()
36
- connection.inputStream.use { input -> val buffer = ByteArray(8192); while (true) { val size = input.read(buffer); if (size < 0) break; if (output.size() + size > MAX_BYTES) throw IndianaClientException("invalid_response"); output.write(buffer, 0, size) } }
37
- try { Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(output.toByteArray())).toString() }
38
- catch (_: Exception) { throw IndianaClientException("invalid_response") }
39
- } else ""
40
- JSONObject().put("status", status).put("headers", headers).put("body", body)
41
- } catch (e: IndianaClientException) { throw e } catch (_: Exception) { throw IndianaClientException("temporary_unavailable") }
42
- finally { connection.disconnect() }
67
+ finally { connection.disconnect() }
68
+ })
43
69
  }
44
70
  }
45
71
  companion object {
@@ -0,0 +1,120 @@
1
+ package ai.tinkerland.indiana
2
+
3
+ import kotlinx.coroutines.Dispatchers
4
+ import kotlinx.coroutines.withContext
5
+ import org.json.JSONObject
6
+ import java.util.UUID
7
+ import java.util.concurrent.atomic.AtomicReference
8
+
9
+ /** Uses the merchant's acknowledged process handle and authenticated session; the backend resolves the Customer. */
10
+ interface IndianaFunnelProcessBridge {
11
+ suspend fun invoke(operation: String, input: JSONObject): JSONObject
12
+ suspend fun detach()
13
+ }
14
+
15
+ /** Owns one credential-free process command journal. The backend owns Customer/Guest credentials and refresh. */
16
+ class IndianaFunnelProcess(
17
+ private val configuration: IndianaConfiguration,
18
+ private val bridge: IndianaFunnelProcessBridge,
19
+ private val commandStorage: IndianaFunnelCommandStorage,
20
+ private val onAuthenticationRequired: ((JSONObject) -> Unit)? = null,
21
+ ) {
22
+ private val epoch = AtomicReference(UUID.randomUUID())
23
+ private val view = AtomicReference<JSONObject?>(null)
24
+ private val stateLock = Any()
25
+ private var runToken: String? = null
26
+ private var presentation: IndianaFunnelSession? = null
27
+ val current: JSONObject? get() = view.get()?.let { JSONObject(it.toString()) }
28
+ private suspend fun record(): JSONObject = withContext(Dispatchers.IO) { commandStorage.read().entry?.let(::JSONObject) ?: JSONObject() }
29
+ private suspend fun update(change: (JSONObject) -> JSONObject) = withContext(Dispatchers.IO) {
30
+ for (attempt in 0 until 8) {
31
+ val saved=commandStorage.read();val record=saved.entry?.let(::JSONObject) ?: JSONObject();val next=change(JSONObject(record.toString()))
32
+ if (IndianaSchema.equal(record,next) || commandStorage.write(saved.revision,next.toString()) != null) return@withContext
33
+ }
34
+ throw IndianaClientException("command_pending")
35
+ }
36
+ private suspend fun accept(raw: JSONObject, original: UUID): JSONObject {
37
+ if (epoch.get()!=original) throw IndianaClientException("identity_changed")
38
+ val reply=JSONObject(raw.toString());IndianaSchema.validate("processResponse",reply)
39
+ val run=reply.getJSONObject("run");val identity=run.getJSONObject("identity");val scope=reply.getJSONObject("scope")
40
+ if(scope.getString("projectId")!=configuration.scope.projectId.toString() || scope.getString("environment")!=configuration.scope.environment.value) throw IndianaClientException("scope_mismatch")
41
+ val tuple=JSONObject().put("scope",scope).put("runId",run.getString("id")).put("source",run.getJSONObject("source"))
42
+ .put("participantId",identity.getString("participantId")).put("startedAs",identity.getString("startedAs")).put("runExpiresAt",run.getString("runExpiresAt"))
43
+ val binding: Any=identity.optJSONObject("binding")?.getString("id") ?: JSONObject.NULL
44
+ update { saved ->
45
+ saved.optJSONObject("origin")?.let { if(!IndianaSchema.equal(it,tuple)) throw IndianaClientException("invalid_response") }
46
+ if(saved.has("bindingId") && !saved.isNull("bindingId") && saved.get("bindingId")!=binding) throw IndianaClientException("identity_changed")
47
+ saved.put("origin",tuple).put("bindingId",binding)
48
+ reply.optJSONObject("settlement")?.takeIf { it.optString("state")=="settled" }?.let { settlement ->
49
+ for(lane in listOf("pending","close")) if(saved.optJSONObject(lane)?.optJSONObject("command")?.optString("requestId")==settlement.optString("requestId")) saved.remove(lane)
50
+ }
51
+ saved
52
+ }
53
+ synchronized(stateLock) {
54
+ if(epoch.get()!=original) throw IndianaClientException("identity_changed")
55
+ if(view.get()==null || run.getLong("revision")>=view.get()!!.getLong("revision")) view.set(JSONObject(run.toString()))
56
+ if(reply.has("runToken")) {val token=reply.getString("runToken");if(runToken!=null && runToken!=token) throw IndianaClientException("invalid_response");runToken=token}
57
+ }
58
+ if(run.optJSONObject("instruction")?.optString("type")=="authentication_required") onAuthenticationRequired?.invoke(JSONObject(run.toString()))
59
+ return reply
60
+ }
61
+ private suspend fun invoke(operation: String,input: JSONObject): JSONObject {
62
+ val original=epoch.get()
63
+ try {return accept(bridge.invoke(operation,JSONObject(input.toString())),original)}
64
+ catch(error: IndianaClientException) {
65
+ if(error.code in setOf("identity_changed","unauthenticated","customer_binding_conflict","request_failed_401")) synchronized(stateLock){view.set(null);presentation?.reset();presentation=null}
66
+ throw error
67
+ }
68
+ }
69
+ suspend fun open(input: JSONObject): JSONObject {
70
+ IndianaSchema.validate("processOpen",input);invoke("open",input);settlePending();return invoke("get",JSONObject())
71
+ }
72
+ suspend fun syncIdentity(): JSONObject {
73
+ invoke("syncIdentity",JSONObject());settlePending();val result=invoke("get",JSONObject());presentation?.get();return result
74
+ }
75
+ suspend fun get(input: JSONObject = JSONObject()): JSONObject {
76
+ val request=JSONObject(input.toString());val saved=record()
77
+ if(request.has("settlementRequestId")) for(lane in listOf("pending","close")) saved.optJSONObject(lane)?.let {
78
+ if(it.getJSONObject("command").getString("requestId")==request.getString("settlementRequestId")) request.put("settlementBindingId",it.get("bindingId"))
79
+ }
80
+ return invoke("get",request)
81
+ }
82
+ suspend fun command(command: JSONObject): JSONObject {
83
+ IndianaSchema.validate("processMutation",command);val run=current ?: throw IndianaClientException("invalid_command")
84
+ val original=epoch.get();val lane=if(command.getString("operation")=="close") "close" else "pending"
85
+ val envelope=JSONObject().put("command",JSONObject(command.toString())).put("bindingId",run.getJSONObject("identity").optJSONObject("binding")?.getString("id") ?: JSONObject.NULL)
86
+ var accepted=envelope
87
+ update { saved ->
88
+ saved.optJSONObject(lane)?.let { pending ->
89
+ if(!IndianaSchema.equal(pending.getJSONObject("command"),command)) throw IndianaClientException("command_pending")
90
+ accepted=JSONObject(pending.toString())
91
+ } ?: saved.put(lane,envelope)
92
+ saved
93
+ }
94
+ if(epoch.get()!=original) throw IndianaClientException("identity_changed")
95
+ return invoke("command",accepted)
96
+ }
97
+ private suspend fun settlePending() {
98
+ val saved=record()
99
+ for(lane in listOf("pending","close")) saved.optJSONObject(lane)?.let {
100
+ get(JSONObject().put("settlementRequestId",it.getJSONObject("command").getString("requestId")).put("settlementBindingId",it.get("bindingId")))
101
+ }
102
+ }
103
+ /** Reuses the native renderer; this process remains the sole command journal owner. */
104
+ fun presentationSession(): IndianaFunnelSession = synchronized(stateLock) {
105
+ presentation ?: IndianaFunnelSession(configuration,object: IndianaHTTP {
106
+ override suspend fun send(path: String,method: String,authorization: String,body: JSONObject?): JSONObject {
107
+ if(path!="funnels" || method!="POST" || body==null) throw IndianaClientException("invalid_command")
108
+ val input=JSONObject(body.toString());input.remove("customerToken");val operation=input.getString("operation")
109
+ val reply=when(operation){"get"->{input.remove("operation");get(input)};"review"->{input.remove("operation");invoke("review",input)};else->command(input)}
110
+ reply.remove("contract");val run=reply.getJSONObject("run");val identity=run.getJSONObject("identity");run.remove("identity");run.put("authenticated",!identity.isNull("binding"))
111
+ if(run.optJSONObject("instruction")?.optString("type")=="authentication_required")run.put("instruction",JSONObject.NULL)
112
+ reply.optJSONObject("settlement")?.remove("accepted");return reply
113
+ }
114
+ },runToken ?: throw IndianaClientException("invalid_command")).also { presentation=it }
115
+ }
116
+ suspend fun detach() {
117
+ synchronized(stateLock){epoch.set(UUID.randomUUID());view.set(null);presentation?.reset();presentation=null;runToken=null}
118
+ bridge.detach()
119
+ }
120
+ }
@@ -25,6 +25,7 @@ class IndianaFunnelSession internal constructor(private val configuration: India
25
25
  private val settlements = java.util.concurrent.ConcurrentHashMap<String,String>()
26
26
  @Volatile private var closeRequested = false
27
27
  private val mutex = Mutex()
28
+ private val closeMutex = Mutex()
28
29
  private val responseMutex = Mutex()
29
30
  private val responseState = Any()
30
31
  @Volatile private var invalidated = false
@@ -51,16 +52,20 @@ class IndianaFunnelSession internal constructor(private val configuration: India
51
52
  if (restored) return@withLock
52
53
  val actor = identity.get();val record = journal.load()
53
54
  if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
54
- pending.set(record?.optJSONObject("command")?.toString());closingCommand.set(record?.optJSONObject("closeCommand")?.toString());purchaseRecovery.set(record?.optJSONObject("purchase"))
55
- closeRequested = record?.optBoolean("closed") == true || record?.optJSONObject("command")?.optString("operation") == "close"
55
+ val command = record?.optJSONObject("command")
56
+ pending.set(command?.takeIf { it.optString("operation") != "close" }?.toString())
57
+ closingCommand.set((command?.takeIf { it.optString("operation") == "close" } ?: record?.optJSONObject("closeCommand"))?.toString())
58
+ purchaseRecovery.set(record?.optJSONObject("purchase"))
59
+ closeRequested = record?.optBoolean("closed") == true || closingCommand.get() != null
56
60
  for (command in listOf(pending.get(),closingCommand.get())) command?.let { uncertain.add(JSONObject(it).getString("requestId")) }
57
61
  restored = true
58
62
  }
59
63
  }
60
- private suspend fun call(command: JSONObject): JSONObject {
64
+ private suspend fun call(command: JSONObject, expectedEpoch: UUID? = null): JSONObject {
61
65
  restoreCommands()
62
66
  if (invalidated) throw IndianaClientException("identity_changed")
63
67
  val actor = identity.get();val body = JSONObject(command.toString());actor.token?.let { body.put("customerToken", it) }
68
+ if (expectedEpoch != null && expectedEpoch != actor.epoch) throw IndianaClientException("identity_changed")
64
69
  if (body.toString().toByteArray(Charsets.UTF_8).size > 262144) throw IndianaClientException("invalid_command")
65
70
  IndianaSchema.validate("command", body)
66
71
  val next = http.send("funnels", "POST", runToken, body)
@@ -112,33 +117,69 @@ class IndianaFunnelSession internal constructor(private val configuration: India
112
117
  suspend fun get(data: JSONObject? = null, purchase: JSONObject? = null): JSONObject { restoreCommands(); val command = JSONObject().put("operation", "get");data?.let { command.put("data", it) };val reference = purchase ?: purchaseRecovery.get()?.let { JSONObject().put("receiptId",it.getString("receiptId")).put("operationId",it.getString("operationId")) };reference?.let { command.put("purchase",it) };return call(command) }
113
118
  private suspend fun mutate(command: JSONObject): JSONObject {
114
119
  restoreCommands()
115
- val original = command.toString();val closing = command.optString("operation") == "close";val id = command.optString("requestId")
116
- if (!closing && closeRequested) throw IndianaClientException("command_pending")
117
- suspend fun dispatch(): JSONObject {
120
+ val actor = identity.get()
121
+ val original = command.toString()
122
+ val closing = command.optString("operation") == "close"
123
+ val id = command.optString("requestId")
124
+ val lane = if (closing) closeMutex else mutex
125
+ if (!lane.tryLock()) throw IndianaClientException("command_pending")
126
+ try {
118
127
  val slot = if (closing) closingCommand else pending
119
- val prior = slot.get();if (prior != null && !IndianaSchema.equal(JSONObject(prior), command)) throw IndianaClientException("command_pending")
120
- slot.set(original);val wasUncertain = !uncertain.add(id)
128
+ val wasUncertain = synchronized(responseState) {
129
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
130
+ if (!closing && closeRequested) throw IndianaClientException("command_pending")
131
+ if (closing && pending.get()?.let { JSONObject(it).optString("requestId") } == id) throw IndianaClientException("invalid_command")
132
+ val prior = slot.get()
133
+ if (prior != null && !IndianaSchema.equal(JSONObject(prior), command)) throw IndianaClientException("command_pending")
134
+ slot.set(original)
135
+ if (closing) closeRequested = true
136
+ uncertain.contains(id)
137
+ }
121
138
  try {
122
139
  journal?.let { it.retain(command,current ?: throw IndianaClientException("invalid_command")) }
123
- return call(command).also {
140
+ synchronized(responseState) {
141
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
142
+ uncertain.add(id)
143
+ }
144
+ return call(command, actor.epoch).also {
124
145
  val state = settlements[id]
125
- if (it.optJSONObject("pendingRequest")?.optString("requestId") != id && state == "settled" && (!closeRequested || closing || it.optString("status") !in setOf("running","waiting"))) { journal?.settle(id);slot.compareAndSet(original,null);uncertain.remove(id) }
146
+ if (it.optJSONObject("pendingRequest")?.optString("requestId") != id && state == "settled" && (!closeRequested || closing || it.optString("status") !in setOf("running","waiting"))) {
147
+ journal?.settle(id)
148
+ synchronized(responseState) {
149
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
150
+ slot.compareAndSet(original,null);uncertain.remove(id)
151
+ }
152
+ }
153
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
126
154
  }
127
155
  } catch (error: Exception) {
128
156
  val code = (error as? IndianaClientException)?.code?.removePrefix("request_failed_")?.toIntOrNull()
129
- if (!wasUncertain && !closeRequested && code != null && code < 500 && code !in setOf(401,425)) { journal?.settle(id);slot.compareAndSet(original,null);uncertain.remove(id) };throw error
157
+ if (!invalidated && identity.get().epoch == actor.epoch && !wasUncertain && code != null && code < 500 && code !in setOf(401,425)) {
158
+ journal?.settle(id)
159
+ synchronized(responseState) {
160
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
161
+ slot.compareAndSet(original,null);uncertain.remove(id)
162
+ }
163
+ }
164
+ throw error
130
165
  }
166
+ } finally {
167
+ lane.unlock()
131
168
  }
132
- if (closing) { closeRequested = true;return dispatch() }
133
- return mutex.withLock { dispatch() }
134
169
  }
135
170
  suspend fun settle(requestId: UUID): JSONObject {
171
+ restoreCommands()
172
+ val actor = identity.get()
136
173
  val id = requestId.toString();settlements.remove(id)
137
- val result = call(JSONObject().put("operation","get").put("settlementRequestId",id))
138
- if (settlements[id] == "settled") {
174
+ val result = call(JSONObject().put("operation","get").put("settlementRequestId",id), actor.epoch)
175
+ if (settlements[id] == "settled" && result.optJSONObject("pendingRequest")?.optString("requestId") != id) {
139
176
  journal?.settle(id)
140
- for (slot in listOf(pending,closingCommand)) { val old = slot.get();if (old != null && JSONObject(old).optString("requestId") == id) slot.compareAndSet(old,null) };uncertain.remove(id)
177
+ synchronized(responseState) {
178
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
179
+ for (slot in listOf(pending,closingCommand)) { val old = slot.get();if (old != null && JSONObject(old).optString("requestId") == id) slot.compareAndSet(old,null) };uncertain.remove(id)
180
+ }
141
181
  }
182
+ if (invalidated || identity.get().epoch != actor.epoch) throw IndianaClientException("identity_changed")
142
183
  return result
143
184
  }
144
185
  private fun base(operation: String, requestId: UUID): JSONObject { val run = current ?: throw IndianaClientException("invalid_command");return JSONObject().put("operation", operation).put("requestId", requestId.toString()).put("nodeId", run.getString("nodeId")).put("visit", run.getInt("visit")).put("revision", run.getInt("revision")) }
@@ -73,10 +73,27 @@ data class GoogleStoreTerms(val productKind: String, val offerKind: String, val
73
73
  }
74
74
  sealed interface GoogleStoreResult {
75
75
  data class Purchased(val evidence: GooglePurchaseEvidence): GoogleStoreResult
76
+ /** The Play flow ended without a purchase: the user dismissed it, or Play refused the payment and showed why. */
76
77
  data object Canceled: GoogleStoreResult
77
78
  data class Failed(val code: String): GoogleStoreResult
78
79
  }
79
80
 
81
+ /**
82
+ * Classifies a Play purchase flow that ended without a purchase. A refused payment ends like a dismissed sheet, as
83
+ * StoreKit reports it after its own payment sheet. Only lost Play connections and internal errors, whose purchase
84
+ * state is unknown, report `store_unavailable`.
85
+ */
86
+ internal fun googleFlowEnded(responseCode: Int, subResponseCode: Int): GoogleStoreResult = when {
87
+ responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> GoogleStoreResult.Canceled
88
+ // Google documents BILLING_UNAVAILABLE in a purchase result as a user billing error, including a refused charge.
89
+ responseCode == BillingClient.BillingResponseCode.BILLING_UNAVAILABLE ||
90
+ subResponseCode == BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS ||
91
+ subResponseCode == BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE -> GoogleStoreResult.Canceled
92
+ responseCode == BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> GoogleStoreResult.Failed("product_unavailable")
93
+ responseCode in setOf(BillingClient.BillingResponseCode.DEVELOPER_ERROR, BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED, BillingClient.BillingResponseCode.ITEM_NOT_OWNED) -> GoogleStoreResult.Failed("purchase_not_started")
94
+ else -> GoogleStoreResult.Failed("store_unavailable")
95
+ }
96
+
80
97
  /** The backend owns acknowledge/consume. Purchase callbacks never grant customer access. */
81
98
  class GoogleStorefront(context: Context, private val onResult: (GoogleStoreResult) -> Unit) : IndianaGoogleStore {
82
99
  private val client = BillingClient.newBuilder(context.applicationContext)
@@ -86,8 +103,7 @@ class GoogleStorefront(context: Context, private val onResult: (GoogleStoreResul
86
103
  if (purchase.products.size != 1 || purchase.quantity != 1 || purchase.purchaseState !in listOf(Purchase.PurchaseState.PURCHASED, Purchase.PurchaseState.PENDING)) onResult(GoogleStoreResult.Failed("unsupported_purchase"))
87
104
  else onResult(GoogleStoreResult.Purchased(GooglePurchaseEvidence(purchase.products.single(),purchase.purchaseToken,purchase.purchaseState == Purchase.PurchaseState.PENDING,purchase.accountIdentifiers?.obfuscatedAccountId,purchase.accountIdentifiers?.obfuscatedProfileId)))
88
105
  }
89
- BillingClient.BillingResponseCode.USER_CANCELED -> onResult(GoogleStoreResult.Canceled)
90
- else -> onResult(GoogleStoreResult.Failed("store_unavailable"))
106
+ else -> onResult(googleFlowEnded(result.responseCode, result.onPurchasesUpdatedSubResponseCode))
91
107
  }
92
108
  }
93
109
  .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
@@ -1,7 +1,11 @@
1
1
  package ai.tinkerland.indiana
2
2
 
3
3
  import kotlinx.coroutines.Dispatchers
4
- import kotlinx.coroutines.withContext
4
+ import kotlinx.coroutines.suspendCancellableCoroutine
5
+ import kotlinx.coroutines.CancellationException
6
+ import kotlin.coroutines.resume
7
+ import kotlin.coroutines.resumeWithException
8
+ import kotlin.coroutines.EmptyCoroutineContext
5
9
  import org.json.JSONObject
6
10
  import java.net.URI
7
11
  import java.net.HttpURLConnection
@@ -19,25 +23,49 @@ internal interface IndianaHTTP { suspend fun send(path: String,method: String,au
19
23
  internal fun requireCurrentProtocol(value: String?) {
20
24
  if (value != IndianaProtocol.wireProtocol) throw IndianaClientException("invalid_response")
21
25
  }
22
- internal class IndianaURLTransport(private val configuration: IndianaConfiguration): IndianaHTTP {
23
- override suspend fun send(path: String,method: String,authorization: String,body: JSONObject?): JSONObject = withContext(Dispatchers.IO) {
26
+ internal class IndianaURLTransport(private val configuration: IndianaConfiguration, loopbackFixtureOrigin: URI? = null): IndianaHTTP {
27
+ // Internal loopback fixture only; public configuration remains HTTPS-only.
28
+ private val origin = loopbackFixtureOrigin?.also { require(it.scheme == "http" && it.host == "127.0.0.1" && it.port > 1024 && it.rawUserInfo == null && it.rawQuery == null && it.rawFragment == null && it.rawPath.isNullOrEmpty()) } ?: URI(configuration.baseUrl)
29
+ override suspend fun send(path: String,method: String,authorization: String,body: JSONObject?): JSONObject = suspendCancellableCoroutine { continuation ->
24
30
  require(!path.contains("..")&&!path.contains("?")&&!path.contains("#")&&!path.startsWith("/"))
25
- val connection=URI(configuration.baseUrl.trimEnd('/')+"/api/v1/"+path).toURL().openConnection() as HttpURLConnection
26
- try {
27
- connection.requestMethod=method;connection.instanceFollowRedirects=false;connection.connectTimeout=configuration.timeoutMs;connection.readTimeout=configuration.timeoutMs;connection.useCaches=false
28
- if(authorization.isNotEmpty()) connection.setRequestProperty("Authorization","Bearer $authorization")
29
- connection.setRequestProperty("Content-Type","application/json")
30
- connection.setRequestProperty("X-Indiana-Project",configuration.scope.projectId.toString());connection.setRequestProperty("X-Indiana-Environment",configuration.scope.environment.value)
31
- connection.setRequestProperty("X-Indiana-Protocol",IndianaProtocol.wireProtocol)
32
- connection.setRequestProperty("X-Indiana-SDK-Version",IndianaSDK.version)
33
- if(body!=null){connection.doOutput=true;val bytes=body.toString().toByteArray(Charsets.UTF_8);connection.setFixedLengthStreamingMode(bytes.size);connection.outputStream.use {it.write(bytes)}}
34
- if(connection.responseCode !in 200..299)throw IndianaClientException("request_failed_${connection.responseCode}")
35
- requireCurrentProtocol(connection.getHeaderField("X-Indiana-Protocol"))
36
- if((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0)>2_097_152)throw IndianaClientException("invalid_response")
37
- val output=ByteArrayOutputStream()
38
- connection.inputStream.use {input->val buffer=ByteArray(8192);while(true){val size=input.read(buffer);if(size<0)break;if(output.size()+size>2_097_152)throw IndianaClientException("invalid_response");output.write(buffer,0,size)}}
39
- safe{JSONObject(output.toString("UTF-8"))}
40
- } catch(e:IndianaClientException){throw e}catch(_:Exception){throw IndianaClientException("temporary_unavailable")}
41
- finally {connection.disconnect()}
31
+ val connection=URI(origin.toString().trimEnd('/')+"/api/v1/"+path).toURL().openConnection() as HttpURLConnection
32
+ // Some implementations block in disconnect while a read owns the stream.
33
+ continuation.invokeOnCancellation {
34
+ Dispatchers.IO.dispatch(EmptyCoroutineContext, Runnable { connection.disconnect() })
35
+ }
36
+ Dispatchers.IO.dispatch(continuation.context, Runnable {
37
+ if (!continuation.isActive) return@Runnable
38
+ try {
39
+ connection.requestMethod=method;connection.instanceFollowRedirects=false;connection.connectTimeout=configuration.timeoutMs;connection.readTimeout=configuration.timeoutMs;connection.useCaches=false
40
+ if(authorization.isNotEmpty()) connection.setRequestProperty("Authorization","Bearer $authorization")
41
+ connection.setRequestProperty("Content-Type","application/json")
42
+ connection.setRequestProperty("X-Indiana-Project",configuration.scope.projectId.toString());connection.setRequestProperty("X-Indiana-Environment",configuration.scope.environment.value)
43
+ connection.setRequestProperty("X-Indiana-Protocol",IndianaProtocol.wireProtocol)
44
+ connection.setRequestProperty("X-Indiana-SDK-Version",IndianaSDK.version)
45
+ if(body!=null){connection.doOutput=true;val bytes=body.toString().toByteArray(Charsets.UTF_8);connection.setFixedLengthStreamingMode(bytes.size);connection.outputStream.use {it.write(bytes)}}
46
+ if(connection.responseCode !in 200..299)throw IndianaClientException("request_failed_${connection.responseCode}")
47
+ requireCurrentProtocol(connection.getHeaderField("X-Indiana-Protocol"))
48
+ if((connection.getHeaderField("Content-Length")?.toLongOrNull() ?: 0)>2_097_152)throw IndianaClientException("invalid_response")
49
+ val output=ByteArrayOutputStream()
50
+ connection.inputStream.use { input ->
51
+ val buffer = ByteArray(8192)
52
+ while (true) {
53
+ if (!continuation.isActive) throw CancellationException()
54
+ val size = input.read(buffer)
55
+ if (size < 0) break
56
+ if (output.size() + size > 2_097_152) throw IndianaClientException("invalid_response")
57
+ output.write(buffer, 0, size)
58
+ }
59
+ }
60
+ continuation.resume(safe{JSONObject(output.toString("UTF-8"))})
61
+ } catch (error: Throwable) {
62
+ continuation.resumeWithException(when (error) {
63
+ is IndianaClientException, is CancellationException -> error
64
+ is Exception -> IndianaClientException("temporary_unavailable")
65
+ else -> error
66
+ })
67
+ }
68
+ finally {connection.disconnect()}
69
+ })
42
70
  }
43
71
  }