pakstr 1.0.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/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/SignerManager.kt +13 -2
- package/android-template/app/src/test/java/com/pakstr/app/signer/AmberRequestChannelTest.kt +294 -0
- package/package.json +1 -1
|
@@ -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
|
+
)
|
|
@@ -7,12 +7,10 @@ import android.net.Uri
|
|
|
7
7
|
import androidx.activity.result.ActivityResult
|
|
8
8
|
import androidx.activity.result.ActivityResultLauncher
|
|
9
9
|
import com.pakstr.app.crypto.NostrEventVerifier
|
|
10
|
-
import com.pakstr.app.debug.AppDebugLogger
|
|
11
10
|
import com.pakstr.app.signer.interfaces.NostrSigner
|
|
12
|
-
import kotlinx.coroutines.
|
|
13
|
-
import kotlinx.coroutines.
|
|
11
|
+
import kotlinx.coroutines.sync.Mutex
|
|
12
|
+
import kotlinx.coroutines.sync.withLock
|
|
14
13
|
import org.json.JSONObject
|
|
15
|
-
import kotlin.coroutines.resume
|
|
16
14
|
|
|
17
15
|
/**
|
|
18
16
|
* NIP-07 signer implementation using Amber as an external signer.
|
|
@@ -31,170 +29,86 @@ import kotlin.coroutines.resume
|
|
|
31
29
|
* After receiving the signature, the event ID is generated and the
|
|
32
30
|
* signature is locally verified before returning the signed event.
|
|
33
31
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* Concurrent requests are serialized and matched to their activity results
|
|
33
|
+
* by [AmberRequestChannel].
|
|
36
34
|
*/
|
|
37
|
-
class AmberSigner(
|
|
38
|
-
private val launcher: ActivityResultLauncher<Intent
|
|
35
|
+
class AmberSigner internal constructor(
|
|
36
|
+
private val launcher: ActivityResultLauncher<Intent>,
|
|
37
|
+
private val channel: AmberRequestChannel
|
|
39
38
|
) : NostrSigner {
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
private var pendingCallback: ((String) -> Unit)? = null
|
|
45
|
-
private var cachedPubkey: String? = null
|
|
46
|
-
override suspend fun getPublicKey(): String {
|
|
47
|
-
|
|
48
|
-
cachedPubkey?.let {
|
|
49
|
-
|
|
50
|
-
return it
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return withTimeout(30_000) {
|
|
55
|
-
|
|
56
|
-
suspendCancellableCoroutine { continuation ->
|
|
57
|
-
|
|
58
|
-
pendingCallback = { pubkey ->
|
|
59
|
-
|
|
60
|
-
cachedPubkey = pubkey
|
|
61
|
-
|
|
62
|
-
if (continuation.isActive) {
|
|
63
|
-
|
|
64
|
-
continuation.resume(pubkey)
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
val intent = Intent(
|
|
71
|
-
|
|
72
|
-
Intent.ACTION_VIEW
|
|
73
|
-
|
|
74
|
-
).apply {
|
|
75
|
-
|
|
76
|
-
data = Uri.parse(
|
|
77
|
-
|
|
78
|
-
"nostrsigner:?type=get_public_key"
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
putExtra(
|
|
83
|
-
|
|
84
|
-
"type",
|
|
85
|
-
|
|
86
|
-
"get_public_key"
|
|
40
|
+
constructor(
|
|
41
|
+
launcher: ActivityResultLauncher<Intent>
|
|
42
|
+
) : this(launcher, AmberRequestChannel())
|
|
87
43
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
try {
|
|
44
|
+
private val session = channel.openSession()
|
|
93
45
|
|
|
94
|
-
|
|
46
|
+
private val pubkeyMutex = Mutex()
|
|
95
47
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (continuation.isActive) {
|
|
99
|
-
|
|
100
|
-
continuation.resume("")
|
|
48
|
+
@Volatile
|
|
49
|
+
private var cachedPubkey: String? = null
|
|
101
50
|
|
|
51
|
+
override suspend fun getPublicKey(): String {
|
|
52
|
+
cachedPubkey?.let { return it }
|
|
53
|
+
|
|
54
|
+
return session.withRequestTimeout {
|
|
55
|
+
// Concurrent callers share one Amber prompt.
|
|
56
|
+
pubkeyMutex.withLock {
|
|
57
|
+
cachedPubkey ?: send(
|
|
58
|
+
Intent(Intent.ACTION_VIEW).apply {
|
|
59
|
+
data = Uri.parse("nostrsigner:?type=get_public_key")
|
|
60
|
+
putExtra("type", "get_public_key")
|
|
102
61
|
}
|
|
103
|
-
|
|
62
|
+
).also { pubkey ->
|
|
63
|
+
if (pubkey.isNotBlank()) cachedPubkey = pubkey
|
|
104
64
|
}
|
|
105
|
-
|
|
106
|
-
continuation.invokeOnCancellation {
|
|
107
|
-
|
|
108
|
-
pendingCallback = null
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
65
|
}
|
|
113
|
-
|
|
114
66
|
}
|
|
115
|
-
|
|
116
67
|
}
|
|
117
68
|
|
|
118
69
|
override suspend fun signEvent(
|
|
119
70
|
eventJson: String
|
|
120
71
|
): String =
|
|
121
|
-
|
|
122
|
-
|
|
72
|
+
session.withRequestTimeout {
|
|
123
73
|
val event = JSONObject(eventJson)
|
|
124
74
|
|
|
125
75
|
val currentPubkey = event.optString("pubkey")
|
|
126
76
|
|
|
127
77
|
if (currentPubkey.isBlank()) {
|
|
128
|
-
|
|
129
78
|
val amberPubkey = getPublicKey()
|
|
130
79
|
|
|
131
80
|
require(amberPubkey.isNotBlank()) {
|
|
132
|
-
|
|
133
81
|
"Amber returned empty pubkey"
|
|
134
|
-
|
|
135
82
|
}
|
|
136
83
|
|
|
137
|
-
event.put(
|
|
138
|
-
|
|
139
|
-
"pubkey",
|
|
140
|
-
|
|
141
|
-
amberPubkey
|
|
84
|
+
event.put("pubkey", amberPubkey)
|
|
85
|
+
}
|
|
142
86
|
|
|
87
|
+
val intent = Intent(
|
|
88
|
+
Intent.ACTION_VIEW,
|
|
89
|
+
Uri.parse(
|
|
90
|
+
"nostrsigner:${Uri.encode(event.toString())}"
|
|
143
91
|
)
|
|
144
|
-
|
|
92
|
+
).apply {
|
|
93
|
+
putExtra("type", "sign_event")
|
|
145
94
|
}
|
|
146
95
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (signature.isEmpty()) {
|
|
152
|
-
|
|
153
|
-
continuation.resume("")
|
|
154
|
-
|
|
155
|
-
} else {
|
|
156
|
-
|
|
157
|
-
val signedEvent =
|
|
158
|
-
NostrEventBuilder.addId(event)
|
|
159
|
-
.apply {
|
|
160
|
-
put(
|
|
161
|
-
"sig",
|
|
162
|
-
signature
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
val verified =
|
|
168
|
-
NostrEventVerifier.verify(
|
|
169
|
-
signedEvent
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (verified) {
|
|
174
|
-
continuation.resume(
|
|
175
|
-
signedEvent.toString()
|
|
176
|
-
)
|
|
177
|
-
} else {
|
|
178
|
-
continuation.resume("")
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
96
|
+
val signature = session.request { requestId ->
|
|
97
|
+
launcher.launch(intent.putExtra("id", requestId))
|
|
98
|
+
}
|
|
182
99
|
|
|
100
|
+
if (signature.isEmpty()) {
|
|
101
|
+
""
|
|
102
|
+
} else {
|
|
103
|
+
val signedEvent =
|
|
104
|
+
NostrEventBuilder.addId(event)
|
|
105
|
+
.apply { put("sig", signature) }
|
|
183
106
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
)
|
|
189
|
-
).apply {
|
|
190
|
-
putExtra(
|
|
191
|
-
"type",
|
|
192
|
-
"sign_event"
|
|
193
|
-
)
|
|
107
|
+
if (NostrEventVerifier.verify(signedEvent)) {
|
|
108
|
+
signedEvent.toString()
|
|
109
|
+
} else {
|
|
110
|
+
""
|
|
194
111
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
launcher.launch(intent)
|
|
198
112
|
}
|
|
199
113
|
}
|
|
200
114
|
|
|
@@ -202,297 +116,135 @@ class AmberSigner(
|
|
|
202
116
|
pubkey: String,
|
|
203
117
|
plaintext: String
|
|
204
118
|
): String =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
pendingCallback = { encrypted ->
|
|
210
|
-
|
|
211
|
-
if (continuation.isActive) {
|
|
212
|
-
continuation.resume(encrypted)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
val intent = Intent(
|
|
217
|
-
Intent.ACTION_VIEW
|
|
218
|
-
).apply {
|
|
219
|
-
|
|
119
|
+
session.withRequestTimeout {
|
|
120
|
+
send(
|
|
121
|
+
Intent(Intent.ACTION_VIEW).apply {
|
|
220
122
|
data = Uri.parse(
|
|
221
123
|
"nostrsigner:?type=nip04_encrypt" +
|
|
222
124
|
"&pubkey=$pubkey" +
|
|
223
125
|
"&plaintext=${Uri.encode(plaintext)}"
|
|
224
126
|
)
|
|
225
|
-
|
|
226
|
-
putExtra(
|
|
227
|
-
|
|
228
|
-
"nip04_encrypt"
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
putExtra(
|
|
232
|
-
"pubkey",
|
|
233
|
-
pubkey
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
putExtra(
|
|
237
|
-
"plaintext",
|
|
238
|
-
plaintext
|
|
239
|
-
)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
|
|
244
|
-
launcher.launch(intent)
|
|
245
|
-
|
|
246
|
-
} catch (_: ActivityNotFoundException) {
|
|
247
|
-
|
|
248
|
-
if (continuation.isActive) {
|
|
249
|
-
continuation.resume("")
|
|
250
|
-
}
|
|
127
|
+
putExtra("type", "nip04_encrypt")
|
|
128
|
+
putExtra("pubkey", pubkey)
|
|
129
|
+
putExtra("plaintext", plaintext)
|
|
251
130
|
}
|
|
252
|
-
|
|
253
|
-
continuation.invokeOnCancellation {
|
|
254
|
-
pendingCallback = null
|
|
255
|
-
}
|
|
256
|
-
}
|
|
131
|
+
)
|
|
257
132
|
}
|
|
258
133
|
|
|
259
134
|
override suspend fun nip04Decrypt(
|
|
260
135
|
pubkey: String,
|
|
261
136
|
ciphertext: String
|
|
262
|
-
): String =
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
Intent.ACTION_VIEW
|
|
275
|
-
).apply {
|
|
276
|
-
|
|
277
|
-
data = Uri.parse(
|
|
278
|
-
"nostrsigner:?type=nip04_decrypt" +
|
|
279
|
-
"&pubkey=$pubkey" +
|
|
280
|
-
"&message=${Uri.encode(ciphertext)}"
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
putExtra(
|
|
284
|
-
"type",
|
|
285
|
-
"nip04_decrypt"
|
|
286
|
-
)
|
|
287
|
-
|
|
288
|
-
putExtra(
|
|
289
|
-
"pubkey",
|
|
290
|
-
pubkey
|
|
291
|
-
)
|
|
292
|
-
|
|
293
|
-
putExtra(
|
|
294
|
-
"message",
|
|
295
|
-
ciphertext
|
|
296
|
-
)
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
try {
|
|
300
|
-
|
|
301
|
-
launcher.launch(intent)
|
|
302
|
-
|
|
303
|
-
} catch (_: ActivityNotFoundException) {
|
|
304
|
-
|
|
305
|
-
if (continuation.isActive) {
|
|
306
|
-
continuation.resume("")
|
|
137
|
+
): String =
|
|
138
|
+
session.withRequestTimeout {
|
|
139
|
+
send(
|
|
140
|
+
Intent(Intent.ACTION_VIEW).apply {
|
|
141
|
+
data = Uri.parse(
|
|
142
|
+
"nostrsigner:?type=nip04_decrypt" +
|
|
143
|
+
"&pubkey=$pubkey" +
|
|
144
|
+
"&message=${Uri.encode(ciphertext)}"
|
|
145
|
+
)
|
|
146
|
+
putExtra("type", "nip04_decrypt")
|
|
147
|
+
putExtra("pubkey", pubkey)
|
|
148
|
+
putExtra("message", ciphertext)
|
|
307
149
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
continuation.invokeOnCancellation {
|
|
311
|
-
pendingCallback = null
|
|
312
|
-
}
|
|
150
|
+
)
|
|
313
151
|
}
|
|
314
|
-
}
|
|
315
152
|
|
|
316
153
|
override suspend fun nip44Encrypt(
|
|
317
|
-
|
|
318
154
|
pubkey: String,
|
|
319
|
-
|
|
320
155
|
plaintext: String
|
|
321
|
-
|
|
322
156
|
): String =
|
|
323
|
-
|
|
324
|
-
withTimeout(30_000) {
|
|
325
|
-
|
|
157
|
+
session.withRequestTimeout {
|
|
326
158
|
val currentUser = getCachedPublicKey()
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
val intent = Intent(
|
|
341
|
-
|
|
342
|
-
Intent.ACTION_VIEW
|
|
343
|
-
|
|
344
|
-
).apply {
|
|
345
|
-
|
|
346
|
-
data = Uri.parse(
|
|
347
|
-
|
|
348
|
-
"nostrsigner:${Uri.encode(plaintext)}"
|
|
349
|
-
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
putExtra(
|
|
353
|
-
|
|
354
|
-
"type",
|
|
355
|
-
|
|
356
|
-
"nip44_encrypt"
|
|
357
|
-
|
|
358
|
-
)
|
|
359
|
-
|
|
360
|
-
putExtra(
|
|
361
|
-
|
|
362
|
-
"pubkey",
|
|
363
|
-
|
|
364
|
-
pubkey
|
|
365
|
-
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
putExtra(
|
|
369
|
-
|
|
370
|
-
"current_user",
|
|
371
|
-
|
|
372
|
-
currentUser
|
|
373
|
-
|
|
374
|
-
)
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
try {
|
|
379
|
-
|
|
380
|
-
launcher.launch(intent)
|
|
381
|
-
|
|
382
|
-
} catch (_: ActivityNotFoundException) {
|
|
383
|
-
|
|
384
|
-
if (continuation.isActive) {
|
|
385
|
-
|
|
386
|
-
continuation.resume("")
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
continuation.invokeOnCancellation {
|
|
393
|
-
|
|
394
|
-
pendingCallback = null
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
159
|
+
send(
|
|
160
|
+
Intent(Intent.ACTION_VIEW).apply {
|
|
161
|
+
data = Uri.parse("nostrsigner:${Uri.encode(plaintext)}")
|
|
162
|
+
putExtra("type", "nip44_encrypt")
|
|
163
|
+
putExtra("pubkey", pubkey)
|
|
164
|
+
putExtra("current_user", currentUser)
|
|
165
|
+
}
|
|
166
|
+
)
|
|
400
167
|
}
|
|
401
168
|
|
|
402
169
|
override suspend fun nip44Decrypt(
|
|
403
170
|
pubkey: String,
|
|
404
171
|
ciphertext: String
|
|
405
172
|
): String =
|
|
406
|
-
|
|
173
|
+
session.withRequestTimeout {
|
|
407
174
|
val currentUser = getCachedPublicKey()
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
val intent = Intent(
|
|
418
|
-
Intent.ACTION_VIEW
|
|
419
|
-
).apply {
|
|
420
|
-
|
|
421
|
-
data = Uri.parse(
|
|
422
|
-
"nostrsigner:${Uri.encode(ciphertext)}"
|
|
423
|
-
)
|
|
424
|
-
|
|
425
|
-
putExtra(
|
|
426
|
-
"type",
|
|
427
|
-
"nip44_decrypt"
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
putExtra(
|
|
431
|
-
"pubkey",
|
|
432
|
-
pubkey
|
|
433
|
-
)
|
|
434
|
-
|
|
435
|
-
putExtra(
|
|
436
|
-
"current_user",
|
|
437
|
-
currentUser
|
|
438
|
-
)
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
try {
|
|
442
|
-
|
|
443
|
-
launcher.launch(intent)
|
|
444
|
-
|
|
445
|
-
} catch (_: ActivityNotFoundException) {
|
|
446
|
-
|
|
447
|
-
if (continuation.isActive) {
|
|
448
|
-
continuation.resume("")
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
continuation.invokeOnCancellation {
|
|
454
|
-
pendingCallback = null
|
|
455
|
-
}
|
|
456
|
-
}
|
|
175
|
+
send(
|
|
176
|
+
Intent(Intent.ACTION_VIEW).apply {
|
|
177
|
+
data = Uri.parse("nostrsigner:${Uri.encode(ciphertext)}")
|
|
178
|
+
putExtra("type", "nip44_decrypt")
|
|
179
|
+
putExtra("pubkey", pubkey)
|
|
180
|
+
putExtra("current_user", currentUser)
|
|
181
|
+
}
|
|
182
|
+
)
|
|
457
183
|
}
|
|
458
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Delivers an Amber activity result to the request that launched it.
|
|
187
|
+
* Returns false if no outstanding request matched and it was dropped.
|
|
188
|
+
*/
|
|
459
189
|
fun onResult(
|
|
460
190
|
result: ActivityResult
|
|
461
|
-
)
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
191
|
+
): Boolean = channel.deliver(result)
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fails this signer's waiting requests and stops it launching Amber;
|
|
195
|
+
* used when the signer is replaced.
|
|
196
|
+
*/
|
|
197
|
+
fun close() {
|
|
198
|
+
session.close()
|
|
199
|
+
}
|
|
466
200
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
201
|
+
/** Launches [intent] and waits for its result; "" if Amber is missing. */
|
|
202
|
+
private suspend fun send(intent: Intent): String {
|
|
203
|
+
return try {
|
|
204
|
+
session.request { requestId ->
|
|
205
|
+
launcher.launch(intent.putExtra("id", requestId))
|
|
470
206
|
}
|
|
471
|
-
|
|
472
|
-
|
|
207
|
+
} catch (_: ActivityNotFoundException) {
|
|
208
|
+
""
|
|
473
209
|
}
|
|
474
|
-
|
|
475
|
-
val value =
|
|
476
|
-
|
|
477
|
-
result.data?.getStringExtra("result")
|
|
478
|
-
|
|
479
|
-
?: result.data?.getStringExtra("event")
|
|
480
|
-
|
|
481
|
-
?: result.data?.getStringExtra("signature")
|
|
482
|
-
|
|
483
|
-
?: result.data?.getStringExtra("plaintext")
|
|
484
|
-
|
|
485
|
-
?: result.data?.getStringExtra("message")
|
|
486
|
-
?: result.data?.getStringExtra("content")
|
|
487
|
-
|
|
488
|
-
?: ""
|
|
489
|
-
|
|
490
|
-
callback?.invoke(value)
|
|
491
210
|
}
|
|
492
211
|
|
|
493
212
|
private suspend fun getCachedPublicKey(): String {
|
|
494
|
-
|
|
495
213
|
return cachedPubkey ?: getPublicKey()
|
|
496
|
-
|
|
497
214
|
}
|
|
498
|
-
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Delivers [result] to the request it answers; see [AmberRequestChannel.deliver]. */
|
|
218
|
+
internal fun AmberRequestChannel.deliver(result: ActivityResult): Boolean {
|
|
219
|
+
val data = result.data
|
|
220
|
+
return deliver(
|
|
221
|
+
data?.getStringExtra("id"),
|
|
222
|
+
amberResultOutcome(
|
|
223
|
+
resultCode = result.resultCode,
|
|
224
|
+
rejected = data?.getBooleanExtra("rejected", false) == true,
|
|
225
|
+
extra = { name -> data?.getStringExtra(name) }
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Maps an Amber activity result to the value its request resolves with.
|
|
232
|
+
* A NIP-55 rejection fails the request and none of its extras are read.
|
|
233
|
+
*/
|
|
234
|
+
internal fun amberResultOutcome(
|
|
235
|
+
resultCode: Int,
|
|
236
|
+
rejected: Boolean,
|
|
237
|
+
extra: (String) -> String?
|
|
238
|
+
): Result<String> = when {
|
|
239
|
+
resultCode != Activity.RESULT_OK -> Result.success("")
|
|
240
|
+
rejected -> Result.failure(AmberRequestRejectedException())
|
|
241
|
+
else -> Result.success(
|
|
242
|
+
extra("result")
|
|
243
|
+
?: extra("event")
|
|
244
|
+
?: extra("signature")
|
|
245
|
+
?: extra("plaintext")
|
|
246
|
+
?: extra("message")
|
|
247
|
+
?: extra("content")
|
|
248
|
+
?: ""
|
|
249
|
+
)
|
|
250
|
+
}
|
|
@@ -39,6 +39,10 @@ class SignerManager(
|
|
|
39
39
|
LocalAccountStore(this.context)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// One channel per launcher, shared by every AmberSigner instance, so a
|
|
43
|
+
// replaced signer's Amber activity still holds the slot until it returns.
|
|
44
|
+
private val amberChannel = AmberRequestChannel()
|
|
45
|
+
|
|
42
46
|
private var activeSigner: NostrSigner? = null
|
|
43
47
|
private var currentUserPubkey: String? = null
|
|
44
48
|
private var pendingPairing: PendingPairing? = null
|
|
@@ -317,7 +321,13 @@ class SignerManager(
|
|
|
317
321
|
"AMBER",
|
|
318
322
|
"Activity result received: ${result.resultCode}"
|
|
319
323
|
)
|
|
320
|
-
|
|
324
|
+
if (!amberChannel.deliver(result)) {
|
|
325
|
+
AppDebugLogger.log(
|
|
326
|
+
context,
|
|
327
|
+
"AMBER",
|
|
328
|
+
"Activity result ignored: no matching request"
|
|
329
|
+
)
|
|
330
|
+
}
|
|
321
331
|
}
|
|
322
332
|
|
|
323
333
|
private fun createAmberSigner(): AmberSigner {
|
|
@@ -336,7 +346,7 @@ class SignerManager(
|
|
|
336
346
|
"Amber signer application is not installed"
|
|
337
347
|
)
|
|
338
348
|
}
|
|
339
|
-
return AmberSigner(amberLauncher)
|
|
349
|
+
return AmberSigner(amberLauncher, amberChannel)
|
|
340
350
|
}
|
|
341
351
|
|
|
342
352
|
private fun createBunkerSigner(): BunkerSigner {
|
|
@@ -427,6 +437,7 @@ class SignerManager(
|
|
|
427
437
|
|
|
428
438
|
private fun closeActiveSigner() {
|
|
429
439
|
(activeSigner as? BunkerSigner)?.close()
|
|
440
|
+
(activeSigner as? AmberSigner)?.close()
|
|
430
441
|
activeSigner = null
|
|
431
442
|
currentUserPubkey = null
|
|
432
443
|
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
package com.pakstr.app.signer
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.signer.exceptions.SignerUnavailableException
|
|
4
|
+
import kotlinx.coroutines.CoroutineScope
|
|
5
|
+
import kotlinx.coroutines.Deferred
|
|
6
|
+
import kotlinx.coroutines.Dispatchers
|
|
7
|
+
import kotlinx.coroutines.SupervisorJob
|
|
8
|
+
import kotlinx.coroutines.async
|
|
9
|
+
import kotlinx.coroutines.cancel
|
|
10
|
+
import kotlinx.coroutines.cancelAndJoin
|
|
11
|
+
import kotlinx.coroutines.channels.Channel
|
|
12
|
+
import kotlinx.coroutines.delay
|
|
13
|
+
import kotlinx.coroutines.runBlocking
|
|
14
|
+
import kotlinx.coroutines.withTimeout
|
|
15
|
+
import org.junit.After
|
|
16
|
+
import org.junit.Assert.assertEquals
|
|
17
|
+
import org.junit.Assert.assertFalse
|
|
18
|
+
import org.junit.Assert.assertThrows
|
|
19
|
+
import org.junit.Assert.assertTrue
|
|
20
|
+
import org.junit.Test
|
|
21
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
22
|
+
|
|
23
|
+
class AmberRequestChannelTest {
|
|
24
|
+
private val launched = Channel<String>(Channel.UNLIMITED)
|
|
25
|
+
private val ids = AtomicInteger()
|
|
26
|
+
|
|
27
|
+
// Failed requests must not cancel the test body that awaits them.
|
|
28
|
+
private val requests = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
29
|
+
|
|
30
|
+
@After
|
|
31
|
+
fun cancelRequests() {
|
|
32
|
+
requests.cancel()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private fun channel(timeoutMillis: Long = 5_000) =
|
|
36
|
+
AmberRequestChannel(timeoutMillis) { "req-${ids.incrementAndGet()}" }
|
|
37
|
+
|
|
38
|
+
@Test
|
|
39
|
+
fun concurrent_requests_launch_one_at_a_time_and_get_their_own_results() = runBlocking {
|
|
40
|
+
val channel = channel()
|
|
41
|
+
val session = channel.openSession()
|
|
42
|
+
val pending = (1..3).map { request(session) }
|
|
43
|
+
|
|
44
|
+
val results = mutableMapOf<String, String>()
|
|
45
|
+
repeat(3) {
|
|
46
|
+
val id = nextLaunch()
|
|
47
|
+
assertNoLaunch()
|
|
48
|
+
assertTrue(channel.deliver(id, Result.success("result-$id")))
|
|
49
|
+
results[id] = "result-$id"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
assertEquals(results.values.toSet(), pending.map { it.await() }.toSet())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun duplicate_and_unexpected_results_are_dropped() = runBlocking {
|
|
57
|
+
val channel = channel()
|
|
58
|
+
val session = channel.openSession()
|
|
59
|
+
assertFalse(channel.deliver(null, Result.success("nobody asked")))
|
|
60
|
+
|
|
61
|
+
val pending = request(session)
|
|
62
|
+
val id = nextLaunch()
|
|
63
|
+
assertTrue(channel.deliver(id, Result.success("right")))
|
|
64
|
+
assertFalse(channel.deliver(id, Result.success("duplicate")))
|
|
65
|
+
assertFalse(channel.deliver(null, Result.success("duplicate")))
|
|
66
|
+
assertEquals("right", pending.await())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Test
|
|
70
|
+
fun results_without_an_id_resolve_the_outstanding_request() = runBlocking {
|
|
71
|
+
val channel = channel()
|
|
72
|
+
val pending = request(channel.openSession())
|
|
73
|
+
nextLaunch()
|
|
74
|
+
|
|
75
|
+
assertTrue(channel.deliver(null, Result.success("")))
|
|
76
|
+
assertEquals("", pending.await())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@Test
|
|
80
|
+
fun mismatched_result_fails_the_request_without_its_data_and_frees_the_slot() = runBlocking {
|
|
81
|
+
val channel = channel()
|
|
82
|
+
val session = channel.openSession()
|
|
83
|
+
val pending = request(session)
|
|
84
|
+
nextLaunch()
|
|
85
|
+
val queued = request(session)
|
|
86
|
+
assertNoLaunch()
|
|
87
|
+
|
|
88
|
+
assertFalse(channel.deliver("other-request", Result.success("secret payload")))
|
|
89
|
+
|
|
90
|
+
val error = failure(pending)
|
|
91
|
+
assertTrue(error is AmberResponseMismatchException)
|
|
92
|
+
assertFalse(error?.message.orEmpty().contains("secret payload"))
|
|
93
|
+
|
|
94
|
+
// The queued request launches and receives only its own result.
|
|
95
|
+
val id = nextLaunch()
|
|
96
|
+
assertEquals("req-2", id)
|
|
97
|
+
assertTrue(channel.deliver(id, Result.success("fresh")))
|
|
98
|
+
assertEquals("fresh", queued.await())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
fun mismatched_result_releases_a_slot_held_by_an_abandoned_request() = runBlocking {
|
|
103
|
+
val channel = channel(timeoutMillis = 100)
|
|
104
|
+
val session = channel.openSession()
|
|
105
|
+
val abandoned = request(session)
|
|
106
|
+
nextLaunch()
|
|
107
|
+
assertTrue(failure(abandoned) is AmberRequestTimeoutException)
|
|
108
|
+
|
|
109
|
+
val next = request(session, timeout = false)
|
|
110
|
+
assertNoLaunch()
|
|
111
|
+
|
|
112
|
+
assertFalse(channel.deliver("other-request", Result.success("secret payload")))
|
|
113
|
+
val id = nextLaunch()
|
|
114
|
+
assertTrue(channel.deliver(id, Result.success("fresh")))
|
|
115
|
+
assertEquals("fresh", next.await())
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Test
|
|
119
|
+
fun rejected_result_fails_the_request_and_frees_the_slot() = runBlocking {
|
|
120
|
+
val channel = channel()
|
|
121
|
+
val session = channel.openSession()
|
|
122
|
+
val pending = request(session)
|
|
123
|
+
val id = nextLaunch()
|
|
124
|
+
|
|
125
|
+
assertTrue(channel.deliver(id, Result.failure(AmberRequestRejectedException())))
|
|
126
|
+
assertTrue(failure(pending) is AmberRequestRejectedException)
|
|
127
|
+
|
|
128
|
+
val next = request(session)
|
|
129
|
+
val nextId = nextLaunch()
|
|
130
|
+
assertTrue(channel.deliver(nextId, Result.success("ok")))
|
|
131
|
+
assertEquals("ok", next.await())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@Test
|
|
135
|
+
fun timeout_fails_with_a_regular_exception_and_late_result_does_not_leak() = runBlocking {
|
|
136
|
+
val channel = channel(timeoutMillis = 100)
|
|
137
|
+
val session = channel.openSession()
|
|
138
|
+
val abandoned = request(session)
|
|
139
|
+
nextLaunch()
|
|
140
|
+
val error = failure(abandoned)
|
|
141
|
+
assertTrue(error is AmberRequestTimeoutException)
|
|
142
|
+
assertEquals("Amber request timed out", error?.message)
|
|
143
|
+
|
|
144
|
+
val next = request(session, timeout = false)
|
|
145
|
+
assertNoLaunch()
|
|
146
|
+
|
|
147
|
+
// The abandoned activity's id-less result drains its own slot.
|
|
148
|
+
assertTrue(channel.deliver(null, Result.success("late")))
|
|
149
|
+
val id = nextLaunch()
|
|
150
|
+
assertTrue(channel.deliver(id, Result.success("fresh")))
|
|
151
|
+
assertEquals("fresh", next.await())
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@Test
|
|
155
|
+
fun cancelled_caller_keeps_its_slot_until_its_result_arrives() = runBlocking {
|
|
156
|
+
val channel = channel()
|
|
157
|
+
val session = channel.openSession()
|
|
158
|
+
val cancelled = request(session)
|
|
159
|
+
val cancelledId = nextLaunch()
|
|
160
|
+
cancelled.cancelAndJoin()
|
|
161
|
+
|
|
162
|
+
val next = request(session)
|
|
163
|
+
assertNoLaunch()
|
|
164
|
+
|
|
165
|
+
assertTrue(channel.deliver(cancelledId, Result.success("late")))
|
|
166
|
+
val id = nextLaunch()
|
|
167
|
+
assertTrue(channel.deliver(id, Result.success("fresh")))
|
|
168
|
+
assertEquals("fresh", next.await())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@Test
|
|
172
|
+
fun requests_timing_out_in_the_queue_are_never_launched() = runBlocking {
|
|
173
|
+
val channel = channel(timeoutMillis = 100)
|
|
174
|
+
val session = channel.openSession()
|
|
175
|
+
val first = request(session)
|
|
176
|
+
val firstId = nextLaunch()
|
|
177
|
+
val queued = request(session)
|
|
178
|
+
|
|
179
|
+
assertTrue(failure(queued) is AmberRequestTimeoutException)
|
|
180
|
+
assertTrue(failure(first) is AmberRequestTimeoutException)
|
|
181
|
+
assertTrue(channel.deliver(firstId, Result.success("late")))
|
|
182
|
+
assertNoLaunch()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@Test
|
|
186
|
+
fun launch_failure_propagates_and_frees_the_signer() = runBlocking {
|
|
187
|
+
val channel = channel()
|
|
188
|
+
val session = channel.openSession()
|
|
189
|
+
assertThrows(IllegalStateException::class.java) {
|
|
190
|
+
runBlocking {
|
|
191
|
+
session.request { throw IllegalStateException("no activity") }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
val next = request(session)
|
|
196
|
+
val id = nextLaunch()
|
|
197
|
+
assertTrue(channel.deliver(id, Result.success("ok")))
|
|
198
|
+
assertEquals("ok", next.await())
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@Test
|
|
202
|
+
fun closing_a_session_fails_its_requests_at_once_and_queued_ones_never_launch() = runBlocking {
|
|
203
|
+
val channel = channel()
|
|
204
|
+
val old = channel.openSession()
|
|
205
|
+
val inFlight = request(old)
|
|
206
|
+
val inFlightId = nextLaunch()
|
|
207
|
+
val queued = request(old)
|
|
208
|
+
assertNoLaunch()
|
|
209
|
+
|
|
210
|
+
old.close()
|
|
211
|
+
|
|
212
|
+
withTimeout(1_000) {
|
|
213
|
+
assertTrue(failure(inFlight) is SignerUnavailableException)
|
|
214
|
+
assertTrue(failure(queued) is SignerUnavailableException)
|
|
215
|
+
}
|
|
216
|
+
assertTrue(failure(request(old)) is SignerUnavailableException)
|
|
217
|
+
|
|
218
|
+
// The old session's Amber activity still drains its own slot.
|
|
219
|
+
assertTrue(channel.deliver(inFlightId, Result.success("late")))
|
|
220
|
+
assertNoLaunch()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@Test
|
|
224
|
+
fun session_closed_just_before_launch_never_launches() = runBlocking {
|
|
225
|
+
lateinit var session: AmberRequestChannel.Session
|
|
226
|
+
// newRequestId runs after the request has its turn, right before launch.
|
|
227
|
+
val channel = AmberRequestChannel(5_000) { session.close(); "req-1" }
|
|
228
|
+
session = channel.openSession()
|
|
229
|
+
|
|
230
|
+
assertTrue(failure(request(session)) is SignerUnavailableException)
|
|
231
|
+
assertNoLaunch()
|
|
232
|
+
assertFalse(channel.deliver(null, Result.success("nobody asked")))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@Test
|
|
236
|
+
fun replacement_session_waits_for_the_closed_sessions_launch_and_gets_only_its_own_result() = runBlocking {
|
|
237
|
+
val channel = channel()
|
|
238
|
+
val old = channel.openSession()
|
|
239
|
+
val stale = request(old)
|
|
240
|
+
val staleId = nextLaunch()
|
|
241
|
+
old.close()
|
|
242
|
+
assertTrue(failure(stale) is SignerUnavailableException)
|
|
243
|
+
|
|
244
|
+
val replacement = channel.openSession()
|
|
245
|
+
val next = request(replacement)
|
|
246
|
+
assertNoLaunch()
|
|
247
|
+
|
|
248
|
+
// The stale result answers the old launch, not the replacement's request.
|
|
249
|
+
assertTrue(channel.deliver(staleId, Result.success("stale payload")))
|
|
250
|
+
val id = nextLaunch()
|
|
251
|
+
assertFalse(next.isCompleted)
|
|
252
|
+
assertTrue(channel.deliver(id, Result.success("fresh")))
|
|
253
|
+
assertEquals("fresh", next.await())
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@Test
|
|
257
|
+
fun result_outcome_follows_nip55_result_codes_and_rejection() {
|
|
258
|
+
val extras = mapOf("result" to "sig", "event" to "{}", "id" to "req-1")
|
|
259
|
+
|
|
260
|
+
assertEquals("sig", amberResultOutcome(RESULT_OK, false, extras::get).getOrThrow())
|
|
261
|
+
assertEquals("{}", amberResultOutcome(RESULT_OK, false, mapOf("event" to "{}")::get).getOrThrow())
|
|
262
|
+
assertEquals("", amberResultOutcome(RESULT_OK, false, emptyMap<String, String>()::get).getOrThrow())
|
|
263
|
+
assertEquals("", amberResultOutcome(RESULT_CANCELED, false, extras::get).getOrThrow())
|
|
264
|
+
|
|
265
|
+
val read = mutableListOf<String>()
|
|
266
|
+
val rejected = amberResultOutcome(RESULT_OK, true) { name -> read += name; extras[name] }
|
|
267
|
+
assertTrue(rejected.exceptionOrNull() is AmberRequestRejectedException)
|
|
268
|
+
assertEquals(emptyList<String>(), read)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private fun request(
|
|
272
|
+
session: AmberRequestChannel.Session,
|
|
273
|
+
timeout: Boolean = true
|
|
274
|
+
): Deferred<String> = requests.async {
|
|
275
|
+
val call = suspend { session.request { id -> launched.trySend(id) } }
|
|
276
|
+
if (timeout) session.withRequestTimeout { call() } else call()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private suspend fun failure(request: Deferred<String>): Throwable? =
|
|
280
|
+
runCatching { request.await() }.exceptionOrNull()
|
|
281
|
+
|
|
282
|
+
private suspend fun nextLaunch(): String =
|
|
283
|
+
withTimeout(2_000) { launched.receive() }
|
|
284
|
+
|
|
285
|
+
private suspend fun assertNoLaunch() {
|
|
286
|
+
delay(150)
|
|
287
|
+
assertTrue(launched.tryReceive().isFailure)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private companion object {
|
|
291
|
+
const val RESULT_OK = -1
|
|
292
|
+
const val RESULT_CANCELED = 0
|
|
293
|
+
}
|
|
294
|
+
}
|