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.
Files changed (19) hide show
  1. package/README.md +1 -1
  2. package/android-template/app/build.gradle.kts +1 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/PakstrWebBridgeTest.kt +153 -0
  4. package/android-template/app/src/main/assets/nip07.js +124 -74
  5. package/android-template/app/src/main/java/com/pakstr/app/AppOrigin.kt +77 -0
  6. package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +5 -5
  7. package/android-template/app/src/main/java/com/pakstr/app/PakstrWebBridge.kt +89 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/WebMessageRouter.kt +186 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +65 -49
  10. package/android-template/app/src/main/java/com/pakstr/app/signer/AmberRequestChannel.kt +156 -0
  11. package/android-template/app/src/main/java/com/pakstr/app/signer/AmberSigner.kt +142 -390
  12. package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +69 -489
  13. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +13 -2
  14. package/android-template/app/src/test/java/com/pakstr/app/AppOriginTest.kt +114 -0
  15. package/android-template/app/src/test/java/com/pakstr/app/WebMessageRouterTest.kt +279 -0
  16. package/android-template/app/src/test/java/com/pakstr/app/signer/AmberRequestChannelTest.kt +294 -0
  17. package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeDispatchTest.kt +113 -0
  18. package/android-template/gradle/libs.versions.toml +2 -0
  19. package/package.json +1 -1
@@ -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
+ }