@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.
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +26549 -12669
- package/dist/index.d.ts +26549 -12669
- package/dist/index.js +2 -2
- package/native-sdk/android/assets/indiana/index.html +42 -42
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +6 -2
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +9 -3
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +46 -22
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +47 -21
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelProcess.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +57 -16
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +18 -2
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +48 -20
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +36 -44
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +82 -59
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +1 -1
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +3 -1
- package/native-sdk/android/java/ai/tinkerland/longplay/Longplay.kt +3 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -1
- package/native-sdk/ios/BundledWebView.swift +5 -1
- package/native-sdk/ios/CustomerSession.swift +7 -3
- package/native-sdk/ios/FeatureClient.swift +75 -29
- package/native-sdk/ios/FunnelConfigurationRelay.swift +1 -0
- package/native-sdk/ios/FunnelProcess.swift +148 -0
- package/native-sdk/ios/FunnelSession.swift +37 -9
- package/native-sdk/ios/HTTPTransport.swift +2 -0
- package/native-sdk/ios/IndianaClient.swift +25 -60
- package/native-sdk/ios/NativeReceiptRelay.swift +3 -3
- package/native-sdk/ios/PaywallSession.swift +5 -4
- package/native-sdk/ios/SDKVersion.swift +1 -1
- package/native-sdk/ios/WebView/index.html +42 -42
- package/native-sdk/ios/funnel-wire-schemas.json +1 -1
- package/package.json +1 -1
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +0 -81
- 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 (
|
|
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 (!
|
|
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:
|
|
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 (!
|
|
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.
|
|
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
|
|
19
|
-
private val pending = mutableMapOf<UUID, CompletableDeferred<Any?>>()
|
|
52
|
+
private val calls = IndianaFeatureCalls()
|
|
20
53
|
var onError: ((Exception) -> Unit)? = null
|
|
21
|
-
private
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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" -> {
|
|
82
|
+
"featuresReady" -> { calls.markReady(); null }
|
|
58
83
|
"featureResult" -> {
|
|
59
84
|
val value = raw as? JSONObject ?: throw IndianaClientException("invalid_command")
|
|
60
|
-
|
|
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.
|
|
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
|
|
25
|
+
return suspendCancellableCoroutine { continuation ->
|
|
22
26
|
val connection = origin.resolve(path).toURL().openConnection() as HttpURLConnection
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
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"))) {
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
}
|