pakstr 0.31.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/android-template/app/build.gradle.kts +1 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/PakstrWebBridgeTest.kt +153 -0
- package/android-template/app/src/main/assets/nip07.js +124 -74
- package/android-template/app/src/main/java/com/pakstr/app/AppOrigin.kt +77 -0
- package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +5 -5
- package/android-template/app/src/main/java/com/pakstr/app/PakstrWebBridge.kt +89 -0
- package/android-template/app/src/main/java/com/pakstr/app/WebMessageRouter.kt +186 -0
- package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +65 -49
- package/android-template/app/src/main/java/com/pakstr/app/signer/AmberRequestChannel.kt +156 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/AmberSigner.kt +142 -390
- package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +69 -489
- package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +13 -2
- package/android-template/app/src/test/java/com/pakstr/app/AppOriginTest.kt +114 -0
- package/android-template/app/src/test/java/com/pakstr/app/WebMessageRouterTest.kt +279 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/AmberRequestChannelTest.kt +294 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeDispatchTest.kt +113 -0
- package/android-template/gradle/libs.versions.toml +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.signer.NostrBridge
|
|
4
|
+
import com.pakstr.app.signer.UnsupportedBridgeMethodException
|
|
5
|
+
import kotlinx.coroutines.CancellationException
|
|
6
|
+
import kotlinx.coroutines.CoroutineScope
|
|
7
|
+
import kotlinx.coroutines.launch
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
/** Where a bridge message came from (android.net.Uri components plus the frame flag). */
|
|
11
|
+
internal data class MessageSource(
|
|
12
|
+
val scheme: String?,
|
|
13
|
+
val host: String?,
|
|
14
|
+
val port: Int,
|
|
15
|
+
val userInfo: String?,
|
|
16
|
+
val path: String?,
|
|
17
|
+
val isMainFrame: Boolean
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
internal fun interface BridgeHandler {
|
|
21
|
+
suspend fun handle(method: String, params: JSONObject?): Any?
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validates and dispatches messages posted to the injected bridge object.
|
|
26
|
+
*
|
|
27
|
+
* Fail closed: messages from subframes or other origins, non-string or
|
|
28
|
+
* oversized data, and malformed envelopes are dropped without a reply.
|
|
29
|
+
* A request `{ v: 1, id, method, params? }` is answered once, only to the
|
|
30
|
+
* frame that sent it, with `{ v: 1, id, ok: true, result }` or
|
|
31
|
+
* `{ v: 1, id, ok: false, error: { error, message } }`. Notifications
|
|
32
|
+
* (no reply) carry no id.
|
|
33
|
+
*/
|
|
34
|
+
internal class WebMessageRouter(
|
|
35
|
+
private val handler: BridgeHandler,
|
|
36
|
+
private val scope: CoroutineScope,
|
|
37
|
+
private val runOnMain: (() -> Unit) -> Unit,
|
|
38
|
+
private val log: (String) -> Unit = {}
|
|
39
|
+
) {
|
|
40
|
+
|
|
41
|
+
fun onMessage(
|
|
42
|
+
source: MessageSource,
|
|
43
|
+
data: String?,
|
|
44
|
+
reply: (String) -> Unit
|
|
45
|
+
) {
|
|
46
|
+
rejectionReason(source, data)?.let { reason ->
|
|
47
|
+
log("Bridge message rejected: $reason")
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
val message = try {
|
|
51
|
+
JSONObject(requireNotNull(data))
|
|
52
|
+
} catch (_: Exception) {
|
|
53
|
+
log("Bridge message rejected: malformed message")
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if (message.opt("v") != PROTOCOL_VERSION) {
|
|
57
|
+
log("Bridge message rejected: unsupported protocol version")
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
val method = message.opt("method") as? String
|
|
62
|
+
val params = when (val value = message.opt("params")) {
|
|
63
|
+
null, JSONObject.NULL -> null
|
|
64
|
+
is JSONObject -> value
|
|
65
|
+
else -> INVALID_PARAMS
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (method in NOTIFICATIONS) {
|
|
69
|
+
if (params !== INVALID_PARAMS) {
|
|
70
|
+
scope.launch {
|
|
71
|
+
try {
|
|
72
|
+
handler.handle(requireNotNull(method), params)
|
|
73
|
+
} catch (error: CancellationException) {
|
|
74
|
+
throw error
|
|
75
|
+
} catch (_: Exception) {
|
|
76
|
+
// Notifications have no reply channel.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
val id = message.opt("id") as? String
|
|
84
|
+
if (id == null || !REQUEST_ID.matches(id)) {
|
|
85
|
+
log("Bridge message rejected: invalid request id")
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (method == null || params === INVALID_PARAMS) {
|
|
89
|
+
val response = failure(id, IllegalArgumentException("Invalid request"))
|
|
90
|
+
runOnMain { reply(response) }
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
scope.launch {
|
|
95
|
+
val response = try {
|
|
96
|
+
success(id, handler.handle(method, params))
|
|
97
|
+
} catch (error: CancellationException) {
|
|
98
|
+
throw error
|
|
99
|
+
} catch (error: Exception) {
|
|
100
|
+
failure(id, error)
|
|
101
|
+
}
|
|
102
|
+
runOnMain { reply(response) }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private fun rejectionReason(
|
|
107
|
+
source: MessageSource,
|
|
108
|
+
data: String?
|
|
109
|
+
): String? = when {
|
|
110
|
+
!source.isMainFrame -> "subframe"
|
|
111
|
+
!AppOrigin.isTrustedOrigin(
|
|
112
|
+
source.scheme,
|
|
113
|
+
source.host,
|
|
114
|
+
source.port,
|
|
115
|
+
source.userInfo,
|
|
116
|
+
source.path
|
|
117
|
+
) -> "untrusted origin"
|
|
118
|
+
data == null -> "non-string message"
|
|
119
|
+
data.length > MAX_MESSAGE_BYTES ||
|
|
120
|
+
data.toByteArray(Charsets.UTF_8).size > MAX_MESSAGE_BYTES -> "message too large"
|
|
121
|
+
else -> null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun success(id: String, result: Any?): String =
|
|
125
|
+
envelope(id)
|
|
126
|
+
.put("ok", true)
|
|
127
|
+
.put("result", result ?: JSONObject.NULL)
|
|
128
|
+
.toString()
|
|
129
|
+
|
|
130
|
+
private fun failure(id: String, error: Exception): String =
|
|
131
|
+
envelope(id)
|
|
132
|
+
.put("ok", false)
|
|
133
|
+
.put("error", JSONObject(NostrBridge.signerErrorResponse(error)))
|
|
134
|
+
.toString()
|
|
135
|
+
|
|
136
|
+
private fun envelope(id: String): JSONObject =
|
|
137
|
+
JSONObject()
|
|
138
|
+
.put("v", PROTOCOL_VERSION)
|
|
139
|
+
.put("id", id)
|
|
140
|
+
|
|
141
|
+
companion object {
|
|
142
|
+
const val PROTOCOL_VERSION = 1
|
|
143
|
+
|
|
144
|
+
/** Same limit as nip07.js, which rejects larger requests before posting them. */
|
|
145
|
+
const val MAX_MESSAGE_BYTES = 1024 * 1024
|
|
146
|
+
|
|
147
|
+
const val DEBUG_LOG_METHOD = "debug.log"
|
|
148
|
+
|
|
149
|
+
private val NOTIFICATIONS = setOf(DEBUG_LOG_METHOD)
|
|
150
|
+
private val REQUEST_ID = Regex("[A-Za-z0-9-]{1,64}")
|
|
151
|
+
private val INVALID_PARAMS = JSONObject()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Routes `nostr.*`, `runtime.*`, and `debug.log` to their native handlers. */
|
|
156
|
+
internal class PakstrBridgeMethods(
|
|
157
|
+
private val nostr: NostrBridge,
|
|
158
|
+
private val runtime: PakstrBridge,
|
|
159
|
+
private val debugLog: (level: String, message: String) -> Unit
|
|
160
|
+
) : BridgeHandler {
|
|
161
|
+
|
|
162
|
+
override suspend fun handle(method: String, params: JSONObject?): Any? =
|
|
163
|
+
when (method) {
|
|
164
|
+
"runtime.getApiBaseUrl" -> runtime.getApiBaseUrl()
|
|
165
|
+
"runtime.setApiBaseUrl" ->
|
|
166
|
+
(params?.opt("url") as? String)?.let(runtime::setApiBaseUrl) ?: false
|
|
167
|
+
"runtime.getNip55CallbackUrl" -> runtime.getNip55CallbackUrl()
|
|
168
|
+
WebMessageRouter.DEBUG_LOG_METHOD -> {
|
|
169
|
+
debugLog(
|
|
170
|
+
params?.opt("level") as? String ?: "LOG",
|
|
171
|
+
params?.opt("message") as? String ?: ""
|
|
172
|
+
)
|
|
173
|
+
null
|
|
174
|
+
}
|
|
175
|
+
else ->
|
|
176
|
+
if (method.startsWith(NOSTR_PREFIX)) {
|
|
177
|
+
nostr.handle(method.removePrefix(NOSTR_PREFIX), params)
|
|
178
|
+
} else {
|
|
179
|
+
throw UnsupportedBridgeMethodException()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private companion object {
|
|
184
|
+
const val NOSTR_PREFIX = "nostr."
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -31,8 +31,7 @@ import kotlinx.coroutines.withContext
|
|
|
31
31
|
* Responsibilities:
|
|
32
32
|
* - Configure secure WebView settings
|
|
33
33
|
* - Load local web application from NanoHTTPD server
|
|
34
|
-
* -
|
|
35
|
-
* - Expose Android signer bridge to JavaScript
|
|
34
|
+
* - Install the origin-checked JavaScript bridge and NIP-07 provider
|
|
36
35
|
* - Handle Web permissions
|
|
37
36
|
* - Cleanup WebView resources
|
|
38
37
|
*/
|
|
@@ -52,6 +51,7 @@ class WebViewController(
|
|
|
52
51
|
private var pendingNip55Result: String? = null
|
|
53
52
|
private var isSetup = false
|
|
54
53
|
private val nip55Scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
54
|
+
private val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
55
55
|
|
|
56
56
|
// Keeps a reference to the runnable to prevent memory leaks if destroyed early
|
|
57
57
|
private val loadRunnable = Runnable {
|
|
@@ -63,8 +63,6 @@ class WebViewController(
|
|
|
63
63
|
//webView.loadUrl("http://127.0.0.1:$port/not-found.html")
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
private var nip07Injected = false
|
|
67
|
-
|
|
68
66
|
fun setup() {
|
|
69
67
|
showLoader()
|
|
70
68
|
setupCookies()
|
|
@@ -121,10 +119,6 @@ class WebViewController(
|
|
|
121
119
|
|
|
122
120
|
hideLoader()
|
|
123
121
|
|
|
124
|
-
if (!nip07Injected) {
|
|
125
|
-
nip07Injected = injectNip07()
|
|
126
|
-
|
|
127
|
-
}
|
|
128
122
|
if (AppDebugLogger.isWebViewLoggingEnabled(context)) {
|
|
129
123
|
injectDebugLogger()
|
|
130
124
|
}
|
|
@@ -235,7 +229,18 @@ class WebViewController(
|
|
|
235
229
|
view: WebView?, request: WebResourceRequest?
|
|
236
230
|
): Boolean {
|
|
237
231
|
val url = request?.url ?: return true
|
|
238
|
-
|
|
232
|
+
val decision = navigationDecision(
|
|
233
|
+
url.scheme,
|
|
234
|
+
url.host,
|
|
235
|
+
url.port,
|
|
236
|
+
url.userInfo,
|
|
237
|
+
request.isForMainFrame
|
|
238
|
+
)
|
|
239
|
+
if (decision == NavigationDecision.REJECT_SUBFRAME_NIP55) {
|
|
240
|
+
logNip55Rejection("subframe request")
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
if (decision == NavigationDecision.NIP55_SIGN_REQUEST) {
|
|
239
244
|
dispatchNip55SignEvent(
|
|
240
245
|
rawUrl = url.toString(),
|
|
241
246
|
activeSignerType = signerManager.getActiveType(),
|
|
@@ -269,7 +274,7 @@ class WebViewController(
|
|
|
269
274
|
)
|
|
270
275
|
return true
|
|
271
276
|
}
|
|
272
|
-
return
|
|
277
|
+
return decision != NavigationDecision.ALLOW
|
|
273
278
|
}
|
|
274
279
|
|
|
275
280
|
}
|
|
@@ -323,25 +328,7 @@ class WebViewController(
|
|
|
323
328
|
}
|
|
324
329
|
}
|
|
325
330
|
|
|
326
|
-
|
|
327
|
-
// Available in JavaScript as window.AndroidBridge.
|
|
328
|
-
webView.addJavascriptInterface(
|
|
329
|
-
|
|
330
|
-
NostrBridge(
|
|
331
|
-
|
|
332
|
-
signerManager = signerManager,
|
|
333
|
-
|
|
334
|
-
webView = webView,
|
|
335
|
-
|
|
336
|
-
),
|
|
337
|
-
|
|
338
|
-
"AndroidBridge"
|
|
339
|
-
|
|
340
|
-
)
|
|
341
|
-
webView.addJavascriptInterface(
|
|
342
|
-
PakstrBridge(RuntimeApiBaseSettings.from(context)),
|
|
343
|
-
"PakstrBridge"
|
|
344
|
-
)
|
|
331
|
+
installBridge()
|
|
345
332
|
|
|
346
333
|
}
|
|
347
334
|
|
|
@@ -406,6 +393,7 @@ class WebViewController(
|
|
|
406
393
|
|
|
407
394
|
fun destroy() {
|
|
408
395
|
nip55Scope.cancel()
|
|
396
|
+
bridgeScope.cancel()
|
|
409
397
|
webView.stopLoading()
|
|
410
398
|
|
|
411
399
|
webView.clearHistory()
|
|
@@ -422,24 +410,48 @@ class WebViewController(
|
|
|
422
410
|
webView.destroy()
|
|
423
411
|
}
|
|
424
412
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Exposes window.nostr and window.pakstr to the app origin's top-level
|
|
415
|
+
* frame only (see PakstrWebBridge). Runs before the first load.
|
|
416
|
+
*/
|
|
417
|
+
private fun installBridge() {
|
|
418
|
+
val providerScript = try {
|
|
419
|
+
context.assets.open("nip07.js")
|
|
429
420
|
.bufferedReader()
|
|
430
|
-
.readText()
|
|
431
|
-
|
|
432
|
-
webView.evaluateJavascript(
|
|
433
|
-
js, null
|
|
434
|
-
)
|
|
435
|
-
true
|
|
421
|
+
.use { it.readText() }
|
|
436
422
|
} catch (e: Exception) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
423
|
+
AppDebugLogger.error(context, "BRIDGE", "Failed loading nip07.js", e)
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
val router = WebMessageRouter(
|
|
427
|
+
handler = PakstrBridgeMethods(
|
|
428
|
+
nostr = NostrBridge(
|
|
429
|
+
signerProvider = signerManager::getConfiguredSigner,
|
|
430
|
+
log = { message -> AppDebugLogger.log(context, "SIGNER", message) },
|
|
431
|
+
logError = { message, error ->
|
|
432
|
+
AppDebugLogger.error(context, "SIGNER", message, error)
|
|
433
|
+
}
|
|
434
|
+
),
|
|
435
|
+
runtime = PakstrBridge(RuntimeApiBaseSettings.from(context)),
|
|
436
|
+
debugLog = ::logWebConsole
|
|
437
|
+
),
|
|
438
|
+
scope = bridgeScope,
|
|
439
|
+
runOnMain = { block -> webView.post { block() } },
|
|
440
|
+
log = { message -> AppDebugLogger.log(context, "BRIDGE", message) }
|
|
441
|
+
)
|
|
442
|
+
PakstrWebBridge.install(webView, router, providerScript) { message ->
|
|
443
|
+
AppDebugLogger.error(context, "BRIDGE", message)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private fun logWebConsole(level: String, message: String) {
|
|
448
|
+
if (!AppDebugLogger.isWebViewLoggingEnabled(context)) {
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
when (level.uppercase()) {
|
|
452
|
+
"ERROR" -> AppDebugLogger.error(context, "WEB-JS", message)
|
|
453
|
+
"WARN" -> AppDebugLogger.log(context, "WEB-WARN", message)
|
|
454
|
+
else -> AppDebugLogger.log(context, "WEB", message)
|
|
443
455
|
}
|
|
444
456
|
}
|
|
445
457
|
|
|
@@ -448,7 +460,8 @@ class WebViewController(
|
|
|
448
460
|
val js = """
|
|
449
461
|
(function(){
|
|
450
462
|
|
|
451
|
-
|
|
463
|
+
const channel = window.__pakstrNative;
|
|
464
|
+
if (!channel) {
|
|
452
465
|
return;
|
|
453
466
|
}
|
|
454
467
|
|
|
@@ -475,9 +488,12 @@ class WebViewController(
|
|
|
475
488
|
})
|
|
476
489
|
.join(" ");
|
|
477
490
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
491
|
+
channel.postMessage(
|
|
492
|
+
JSON.stringify({
|
|
493
|
+
v: 1,
|
|
494
|
+
method: "debug.log",
|
|
495
|
+
params: { level, message }
|
|
496
|
+
})
|
|
481
497
|
);
|
|
482
498
|
|
|
483
499
|
} catch(e){
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
package com.pakstr.app.signer
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.signer.exceptions.SignerUnavailableException
|
|
4
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
5
|
+
import kotlinx.coroutines.TimeoutCancellationException
|
|
6
|
+
import kotlinx.coroutines.coroutineScope
|
|
7
|
+
import kotlinx.coroutines.launch
|
|
8
|
+
import kotlinx.coroutines.sync.Mutex
|
|
9
|
+
import kotlinx.coroutines.sync.withLock
|
|
10
|
+
import kotlinx.coroutines.withTimeout
|
|
11
|
+
import java.util.UUID
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Matches NIP-55 activity results to the request that launched them.
|
|
15
|
+
*
|
|
16
|
+
* One ActivityResultLauncher delivers every result through the same
|
|
17
|
+
* callback, so one channel serves the launcher and at most one signer
|
|
18
|
+
* activity is outstanding at a time. Each launch carries an `id` extra.
|
|
19
|
+
*
|
|
20
|
+
* Every launch yields exactly one result. A request abandoned by its caller
|
|
21
|
+
* (timeout, cancellation, closed session) therefore keeps its slot until its
|
|
22
|
+
* result arrives, and a result echoing a different id means the matching one
|
|
23
|
+
* can never follow: the outstanding request fails without receiving that
|
|
24
|
+
* result's data and the slot is released.
|
|
25
|
+
*/
|
|
26
|
+
internal class AmberRequestChannel(
|
|
27
|
+
private val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS,
|
|
28
|
+
private val newRequestId: () -> String = { UUID.randomUUID().toString() }
|
|
29
|
+
) {
|
|
30
|
+
private val mutex = Mutex()
|
|
31
|
+
private val lock = Any()
|
|
32
|
+
private var outstanding: Outstanding? = null // guarded by lock
|
|
33
|
+
|
|
34
|
+
private class Outstanding(val id: String) {
|
|
35
|
+
val result = CompletableDeferred<String>()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Opens a request session for one AmberSigner instance. */
|
|
39
|
+
fun openSession(): Session = Session()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolves the outstanding request with [outcome]. A [requestId] of null
|
|
43
|
+
* (signers that do not echo ids, cancelled activities) matches the
|
|
44
|
+
* outstanding request. Returns false if the result was not delivered.
|
|
45
|
+
*/
|
|
46
|
+
fun deliver(requestId: String?, outcome: Result<String>): Boolean {
|
|
47
|
+
val current = synchronized(lock) {
|
|
48
|
+
outstanding.also { outstanding = null }
|
|
49
|
+
} ?: return false
|
|
50
|
+
if (requestId != null && requestId != current.id) {
|
|
51
|
+
current.result.completeExceptionally(AmberResponseMismatchException())
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
outcome.fold(current.result::complete, current.result::completeExceptionally)
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private suspend fun exchange(
|
|
59
|
+
session: Session,
|
|
60
|
+
start: (requestId: String) -> Unit
|
|
61
|
+
): String = mutex.withLock {
|
|
62
|
+
// Wait out an abandoned launch so its result cannot resolve this one.
|
|
63
|
+
synchronized(lock) { outstanding }?.result?.join()
|
|
64
|
+
|
|
65
|
+
val current = Outstanding(newRequestId())
|
|
66
|
+
// Holding the session lock makes close() and launch mutually exclusive.
|
|
67
|
+
synchronized(session) {
|
|
68
|
+
if (session.closed) throw closedException()
|
|
69
|
+
synchronized(lock) { outstanding = current }
|
|
70
|
+
try {
|
|
71
|
+
start(current.id)
|
|
72
|
+
} catch (error: Throwable) {
|
|
73
|
+
synchronized(lock) {
|
|
74
|
+
if (outstanding === current) outstanding = null
|
|
75
|
+
}
|
|
76
|
+
throw error
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
current.result.await()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
inner class Session internal constructor() {
|
|
83
|
+
internal var closed = false // guarded by this
|
|
84
|
+
private val waiters = mutableSetOf<CompletableDeferred<String>>()
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Runs [block] under the request timeout, failing with
|
|
88
|
+
* [AmberRequestTimeoutException] instead of a cancellation so callers
|
|
89
|
+
* still get an error response.
|
|
90
|
+
*/
|
|
91
|
+
suspend fun <T> withRequestTimeout(block: suspend () -> T): T {
|
|
92
|
+
return try {
|
|
93
|
+
withTimeout(timeoutMillis) { block() }
|
|
94
|
+
} catch (_: TimeoutCancellationException) {
|
|
95
|
+
throw AmberRequestTimeoutException()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Waits for the signer to be free, calls [start] with the request id
|
|
101
|
+
* and suspends until the matching result is delivered. If [start]
|
|
102
|
+
* throws, nothing is outstanding and the exception propagates.
|
|
103
|
+
*/
|
|
104
|
+
suspend fun request(start: (requestId: String) -> Unit): String = coroutineScope {
|
|
105
|
+
val waiter = CompletableDeferred<String>()
|
|
106
|
+
synchronized(this@Session) {
|
|
107
|
+
if (closed) throw closedException()
|
|
108
|
+
waiters += waiter
|
|
109
|
+
}
|
|
110
|
+
val worker = launch {
|
|
111
|
+
try {
|
|
112
|
+
waiter.complete(exchange(this@Session, start))
|
|
113
|
+
} catch (error: Throwable) {
|
|
114
|
+
waiter.completeExceptionally(error)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
waiter.await()
|
|
119
|
+
} finally {
|
|
120
|
+
worker.cancel()
|
|
121
|
+
synchronized(this@Session) { waiters -= waiter }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fails this session's waiting and queued requests and rejects new
|
|
127
|
+
* ones. A launch already in flight keeps the slot until its result.
|
|
128
|
+
*/
|
|
129
|
+
fun close() {
|
|
130
|
+
val pending = synchronized(this) {
|
|
131
|
+
closed = true
|
|
132
|
+
waiters.toList()
|
|
133
|
+
}
|
|
134
|
+
pending.forEach { it.completeExceptionally(closedException()) }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private fun closedException() =
|
|
139
|
+
SignerUnavailableException("Amber signer was closed")
|
|
140
|
+
|
|
141
|
+
companion object {
|
|
142
|
+
const val DEFAULT_TIMEOUT_MILLIS = 30_000L
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
class AmberRequestTimeoutException : Exception(
|
|
147
|
+
"Amber request timed out"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
class AmberRequestRejectedException : Exception(
|
|
151
|
+
"Amber request was rejected"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
class AmberResponseMismatchException : Exception(
|
|
155
|
+
"Amber returned a response for a different request"
|
|
156
|
+
)
|