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.
@@ -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.suspendCancellableCoroutine
13
- import kotlinx.coroutines.withTimeout
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
- * The class uses suspendCancellableCoroutine to bridge the asynchronous
35
- * Android Activity Result API with Kotlin coroutines.
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
- // Stores the current pending signer request.
42
- // Android intents return results asynchronously, so the callback is used
43
- // to resume the suspended coroutine when Amber responds.
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
- launcher.launch(intent)
46
+ private val pubkeyMutex = Mutex()
95
47
 
96
- } catch (_: ActivityNotFoundException) {
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
- withTimeout(30_000) {
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
- suspendCancellableCoroutine { continuation ->
148
-
149
- pendingCallback = { signature ->
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
- val intent = Intent(
185
- Intent.ACTION_VIEW,
186
- Uri.parse(
187
- "nostrsigner:${Uri.encode(event.toString())}"
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
- withTimeout(30_000) {
206
-
207
- suspendCancellableCoroutine { continuation ->
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
- "type",
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 = withTimeout(30_000) {
263
-
264
- suspendCancellableCoroutine { continuation ->
265
-
266
- pendingCallback = { result ->
267
-
268
- if (continuation.isActive) {
269
- continuation.resume(result)
270
- }
271
- }
272
-
273
- val intent = Intent(
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
- suspendCancellableCoroutine { continuation ->
329
-
330
- pendingCallback = { encrypted ->
331
-
332
- if (continuation.isActive) {
333
-
334
- continuation.resume(encrypted)
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
- withTimeout(30_000) {
173
+ session.withRequestTimeout {
407
174
  val currentUser = getCachedPublicKey()
408
- suspendCancellableCoroutine { continuation ->
409
-
410
- pendingCallback = { result ->
411
-
412
- if (continuation.isActive) {
413
- continuation.resume(result)
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
- val callback = pendingCallback
464
-
465
- pendingCallback = null
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
- if (result.resultCode != Activity.RESULT_OK) {
468
- callback?.let {
469
- it("")
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
- return
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
- (activeSigner as? AmberSigner)?.onResult(result)
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",