pakstr 0.19.3 → 0.21.0

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 (58) hide show
  1. package/README.md +1 -1
  2. package/android-template/app/build.gradle.kts +4 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +273 -0
  4. package/android-template/app/src/main/assets/www/index.html +0 -6
  5. package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +20 -4
  6. package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +136 -12
  7. package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Hash.kt +56 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Signer.kt +104 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Verifier.kt +20 -170
  10. package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip44Crypto.kt +513 -0
  11. package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip46ClientKey.kt +140 -0
  12. package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventHasher.kt +3 -28
  13. package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventSerializer.kt +74 -0
  14. package/android-template/app/src/main/java/com/pakstr/app/crypto/Secp256k1.kt +100 -0
  15. package/android-template/app/src/main/java/com/pakstr/app/signer/BunkerSigner.kt +32 -7
  16. package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +36 -9
  17. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +240 -163
  18. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConfig.kt +175 -0
  19. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConnectionStore.kt +144 -0
  20. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Client.kt +79 -0
  21. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Models.kt +319 -0
  22. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingClient.kt +159 -0
  23. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingQrCode.kt +35 -0
  24. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingUri.kt +104 -0
  25. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Protocol.kt +695 -0
  26. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46RelayTransport.kt +489 -0
  27. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/RelayNip46Client.kt +403 -0
  28. package/android-template/app/src/main/java/com/pakstr/app/utils/BunkerPairingDialog.kt +82 -0
  29. package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +78 -17
  30. package/android-template/app/src/main/res/layout/activity_layout.xml +11 -0
  31. package/android-template/app/src/main/res/layout/dialog_bunker_pairing.xml +75 -0
  32. package/android-template/app/src/main/res/layout/dialog_picker.xml +60 -31
  33. package/android-template/app/src/main/res/values/strings.xml +27 -0
  34. package/android-template/app/src/test/java/com/pakstr/app/LocalServerQueryKeepAliveTest.kt +118 -0
  35. package/android-template/app/src/test/java/com/pakstr/app/crypto/Bip340SignerTest.kt +133 -0
  36. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip44CryptoTest.kt +199 -0
  37. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyProtocolTest.kt +82 -0
  38. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyTest.kt +52 -0
  39. package/android-template/app/src/test/java/com/pakstr/app/crypto/NostrEventSerializerTest.kt +42 -0
  40. package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeErrorTest.kt +32 -0
  41. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/BunkerConfigTest.kt +120 -0
  42. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ModelsTest.kt +111 -0
  43. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingClientTest.kt +271 -0
  44. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingQrCodeTest.kt +54 -0
  45. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingUriTest.kt +80 -0
  46. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ProtocolTest.kt +578 -0
  47. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46RelayTransportTest.kt +508 -0
  48. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/RelayNip46ClientTest.kt +678 -0
  49. package/android-template/gradle/libs.versions.toml +8 -0
  50. package/dist/cli.js +8 -0
  51. package/dist/commands/init.js +16 -0
  52. package/dist/commands/nsite.js +38 -0
  53. package/dist/commands/publish.js +50 -2
  54. package/dist/commands/run.js +5 -5
  55. package/dist/core/blossom.js +2 -2
  56. package/dist/core/nsite.js +176 -0
  57. package/dist/core/pakstrConfig.js +85 -6
  58. package/package.json +1 -1
@@ -0,0 +1,403 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import com.pakstr.app.crypto.Nip46ClientKey
4
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
5
+ import kotlinx.coroutines.CancellableContinuation
6
+ import kotlinx.coroutines.sync.Mutex
7
+ import kotlinx.coroutines.sync.withLock
8
+ import kotlinx.coroutines.suspendCancellableCoroutine
9
+ import java.util.concurrent.Executors
10
+ import java.util.concurrent.ScheduledExecutorService
11
+ import java.util.concurrent.ScheduledFuture
12
+ import java.util.concurrent.TimeUnit
13
+ import java.util.concurrent.atomic.AtomicReference
14
+ import kotlin.coroutines.resume
15
+ import kotlin.coroutines.resumeWithException
16
+
17
+ internal class RelayNip46Client(
18
+ private val protocol: Nip46Protocol,
19
+ private val transport: Nip46Transport,
20
+ private val requestTimeoutMillis: Long = DEFAULT_REQUEST_TIMEOUT_MILLIS,
21
+ private val scheduler: ScheduledExecutorService =
22
+ Executors.newSingleThreadScheduledExecutor(),
23
+ private val ownedClientKey: Nip46ClientKey? = null,
24
+ private val authChallengeHandler: ((String) -> Unit)? = null,
25
+ initialUserPubkey: String? = null,
26
+ private val userPubkeyHandler: ((String) -> Unit)? = null,
27
+ private val activationHandler: (() -> Unit)? = null,
28
+ private val authChallengeTimeoutMillis: Long =
29
+ DEFAULT_AUTH_CHALLENGE_TIMEOUT_MILLIS,
30
+ private val requestedPermissions: String? = null,
31
+ private val clientMetadataJson: String? = null,
32
+ private var connected: Boolean = false
33
+ ) : Nip46Client {
34
+ constructor(
35
+ config: BunkerConfig,
36
+ clientKey: Nip46ClientKey = Nip46ClientKey.generate(),
37
+ connectionFactory: Nip46RelayConnectionFactory =
38
+ OkHttpNip46RelayConnectionFactory(),
39
+ requestTimeoutMillis: Long = DEFAULT_REQUEST_TIMEOUT_MILLIS,
40
+ authChallengeHandler: ((String) -> Unit)? = null,
41
+ initialUserPubkey: String? = null,
42
+ userPubkeyHandler: ((String) -> Unit)? = null,
43
+ activationHandler: (() -> Unit)? = null,
44
+ authChallengeTimeoutMillis: Long =
45
+ DEFAULT_AUTH_CHALLENGE_TIMEOUT_MILLIS,
46
+ requestedPermissions: String? = null,
47
+ clientMetadataJson: String? = null,
48
+ connected: Boolean = false
49
+ ) : this(
50
+ protocol = Nip46Protocol(clientKey, config),
51
+ transport =
52
+ Nip46RelayTransport(
53
+ relayUrls = config.relayUrls,
54
+ remoteSignerPubkey = config.remoteSignerPubkey,
55
+ clientPubkey = clientKey.publicKeyHex,
56
+ connectionFactory = connectionFactory
57
+ ),
58
+ requestTimeoutMillis = requestTimeoutMillis,
59
+ ownedClientKey = clientKey,
60
+ authChallengeHandler = authChallengeHandler,
61
+ initialUserPubkey = initialUserPubkey,
62
+ userPubkeyHandler = userPubkeyHandler,
63
+ activationHandler = activationHandler,
64
+ authChallengeTimeoutMillis = authChallengeTimeoutMillis,
65
+ requestedPermissions = requestedPermissions,
66
+ clientMetadataJson = clientMetadataJson,
67
+ connected = connected
68
+ )
69
+ private val lock = Any()
70
+ private val activationMutex = Mutex()
71
+ private val pendingRequests = mutableMapOf<String, PendingRequest>()
72
+ private var userSigningPubkey: String? = initialUserPubkey
73
+ private var closed = false
74
+
75
+ init {
76
+ transport.start(::receiveEvent)
77
+ }
78
+
79
+ override suspend fun getPublicKey(): String {
80
+ return activate()
81
+ }
82
+
83
+ override suspend fun signEvent(eventJson: String): String {
84
+ val signingPubkey = activate()
85
+ return execute(
86
+ protocol.prepareSignEvent(
87
+ eventJson,
88
+ signingPubkey
89
+ )
90
+ )
91
+ }
92
+
93
+ override suspend fun nip04Encrypt(
94
+ pubkey: String,
95
+ plaintext: String
96
+ ): String {
97
+ activate()
98
+ return execute(protocol.prepareNip04Encrypt(pubkey, plaintext))
99
+ }
100
+
101
+ override suspend fun nip04Decrypt(
102
+ pubkey: String,
103
+ ciphertext: String
104
+ ): String {
105
+ activate()
106
+ return execute(protocol.prepareNip04Decrypt(pubkey, ciphertext))
107
+ }
108
+
109
+ override suspend fun nip44Encrypt(
110
+ pubkey: String,
111
+ plaintext: String
112
+ ): String {
113
+ activate()
114
+ return execute(protocol.prepareNip44Encrypt(pubkey, plaintext))
115
+ }
116
+
117
+ override suspend fun nip44Decrypt(
118
+ pubkey: String,
119
+ ciphertext: String
120
+ ): String {
121
+ activate()
122
+ return execute(protocol.prepareNip44Decrypt(pubkey, ciphertext))
123
+ }
124
+
125
+ private suspend fun activate(): String {
126
+ synchronized(lock) {
127
+ userSigningPubkey?.let { return it }
128
+ if (closed) {
129
+ throw closedException()
130
+ }
131
+ }
132
+
133
+ return activationMutex.withLock {
134
+ synchronized(lock) {
135
+ userSigningPubkey?.let { return@withLock it }
136
+ if (closed) {
137
+ throw closedException()
138
+ }
139
+ }
140
+
141
+ if (!connected) {
142
+ execute(
143
+ protocol.prepareConnect(
144
+ requestedPermissions,
145
+ clientMetadataJson
146
+ )
147
+ )
148
+ connected = true
149
+ activationHandler?.invoke()
150
+ protocol.clearPairingSecret()
151
+ }
152
+ val resolved =
153
+ try {
154
+ execute(protocol.prepareGetPublicKey())
155
+ } catch (_: Nip46RequestTimeoutException) {
156
+ execute(protocol.prepareGetPublicKey())
157
+ }
158
+ synchronized(lock) {
159
+ if (closed) {
160
+ throw closedException()
161
+ }
162
+ userSigningPubkey = resolved
163
+ }
164
+ userPubkeyHandler?.invoke(resolved)
165
+ resolved
166
+ }
167
+ }
168
+
169
+ override fun disconnect() {
170
+ val outbound =
171
+ synchronized(lock) {
172
+ if (closed) {
173
+ return
174
+ }
175
+ try {
176
+ protocol.prepareDisconnect()
177
+ } catch (_: Exception) {
178
+ return
179
+ }
180
+ }
181
+ try {
182
+ transport.publish(
183
+ outbound.context.id,
184
+ outbound.eventJson
185
+ )
186
+ } catch (_: Exception) {
187
+ // Disconnect is best-effort; local logout must still complete.
188
+ } finally {
189
+ outbound.context.close()
190
+ }
191
+ }
192
+
193
+ override fun close() {
194
+ val requests =
195
+ synchronized(lock) {
196
+ if (closed) {
197
+ return
198
+ }
199
+ closed = true
200
+ userSigningPubkey = null
201
+ val snapshot = pendingRequests.values.toList()
202
+ pendingRequests.clear()
203
+ snapshot
204
+ }
205
+ requests.forEach { pending ->
206
+ pending.timeout.get()?.cancel(false)
207
+ pending.outbound.context.close()
208
+ pending.continuation.resumeWithException(closedException())
209
+ }
210
+ transport.close()
211
+ scheduler.shutdownNow()
212
+ ownedClientKey?.close()
213
+ }
214
+
215
+ private suspend fun execute(
216
+ outbound: Nip46OutboundRequest
217
+ ): String {
218
+ return suspendCancellableCoroutine { continuation ->
219
+ val timeoutReference = AtomicReference<ScheduledFuture<*>?>()
220
+ val pending = PendingRequest(
221
+ outbound,
222
+ continuation,
223
+ timeoutReference
224
+ )
225
+ val accepted =
226
+ synchronized(lock) {
227
+ if (closed) {
228
+ false
229
+ } else {
230
+ pendingRequests[outbound.context.id] = pending
231
+ timeoutReference.set(
232
+ scheduler.schedule(
233
+ { timeout(outbound.context.id) },
234
+ requestTimeoutMillis,
235
+ TimeUnit.MILLISECONDS
236
+ )
237
+ )
238
+ true
239
+ }
240
+ }
241
+ if (!accepted) {
242
+ outbound.context.close()
243
+ continuation.resumeWithException(closedException())
244
+ return@suspendCancellableCoroutine
245
+ }
246
+
247
+ continuation.invokeOnCancellation {
248
+ cancel(outbound.context.id)
249
+ }
250
+ try {
251
+ transport.publish(
252
+ outbound.context.id,
253
+ outbound.eventJson
254
+ )
255
+ } catch (_: Exception) {
256
+ fail(
257
+ outbound.context.id,
258
+ SignerUnavailableException(
259
+ "Unable to publish NIP-46 request"
260
+ )
261
+ )
262
+ }
263
+ }
264
+ }
265
+
266
+ private fun receiveEvent(eventJson: String) {
267
+ val verified =
268
+ try {
269
+ protocol.decodeResponseEvent(eventJson)
270
+ } catch (_: Exception) {
271
+ return
272
+ }
273
+ val requestId = verified.response.id
274
+ val pending =
275
+ synchronized(lock) {
276
+ pendingRequests[requestId]
277
+ } ?: return
278
+
279
+ val outcome =
280
+ try {
281
+ protocol.correlateResponse(
282
+ verified,
283
+ pending.outbound.context
284
+ )
285
+ } catch (_: Exception) {
286
+ return
287
+ }
288
+ when (outcome) {
289
+ is Nip46ResponseOutcome.Success ->
290
+ complete(requestId, outcome.result)
291
+ is Nip46ResponseOutcome.RemoteError ->
292
+ fail(
293
+ requestId,
294
+ Nip46RemoteException()
295
+ )
296
+ is Nip46ResponseOutcome.AuthChallenge -> {
297
+ val handler = authChallengeHandler
298
+ if (handler == null) {
299
+ fail(
300
+ requestId,
301
+ Nip46AuthChallengeException()
302
+ )
303
+ } else {
304
+ try {
305
+ extendTimeoutForAuthChallenge(
306
+ requestId,
307
+ pending
308
+ )
309
+ handler(outcome.url)
310
+ } catch (_: Exception) {
311
+ fail(
312
+ requestId,
313
+ Nip46AuthChallengeException()
314
+ )
315
+ }
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ private fun extendTimeoutForAuthChallenge(
322
+ requestId: String,
323
+ pending: PendingRequest
324
+ ) {
325
+ val replacement =
326
+ scheduler.schedule(
327
+ { timeout(requestId) },
328
+ authChallengeTimeoutMillis,
329
+ TimeUnit.MILLISECONDS
330
+ )
331
+ val previous = pending.timeout.getAndSet(replacement)
332
+ previous?.cancel(false)
333
+ synchronized(lock) {
334
+ if (pendingRequests[requestId] !== pending) {
335
+ replacement.cancel(false)
336
+ }
337
+ }
338
+ }
339
+
340
+ private fun complete(
341
+ requestId: String,
342
+ result: String
343
+ ) {
344
+ val pending = remove(requestId) ?: return
345
+ pending.continuation.resume(result)
346
+ }
347
+
348
+ private fun fail(
349
+ requestId: String,
350
+ error: Exception
351
+ ) {
352
+ val pending = remove(requestId) ?: return
353
+ pending.continuation.resumeWithException(error)
354
+ }
355
+
356
+ private fun timeout(requestId: String) {
357
+ fail(requestId, Nip46RequestTimeoutException())
358
+ }
359
+
360
+ private fun cancel(requestId: String) {
361
+ remove(requestId)
362
+ }
363
+
364
+ private fun remove(requestId: String): PendingRequest? {
365
+ val pending =
366
+ synchronized(lock) {
367
+ pendingRequests.remove(requestId)
368
+ } ?: return null
369
+ pending.timeout.get()?.cancel(false)
370
+ transport.cancel(requestId)
371
+ pending.outbound.context.close()
372
+ return pending
373
+ }
374
+
375
+ private fun closedException(): SignerUnavailableException {
376
+ return SignerUnavailableException(
377
+ "Bunker signer is closed"
378
+ )
379
+ }
380
+
381
+ private class PendingRequest(
382
+ val outbound: Nip46OutboundRequest,
383
+ val continuation: CancellableContinuation<String>,
384
+ val timeout: AtomicReference<ScheduledFuture<*>?>
385
+ )
386
+
387
+ private companion object {
388
+ const val DEFAULT_REQUEST_TIMEOUT_MILLIS = 30_000L
389
+ const val DEFAULT_AUTH_CHALLENGE_TIMEOUT_MILLIS = 5 * 60_000L
390
+ }
391
+ }
392
+
393
+ internal class Nip46AuthChallengeException : Exception(
394
+ "Remote signer requires authorization"
395
+ )
396
+
397
+ internal class Nip46RemoteException : Exception(
398
+ "Remote signer rejected the NIP-46 request"
399
+ )
400
+
401
+ internal class Nip46RequestTimeoutException : Exception(
402
+ "NIP-46 request timed out"
403
+ )
@@ -0,0 +1,82 @@
1
+ package com.pakstr.app.utils
2
+
3
+ import android.content.ClipData
4
+ import android.content.ClipboardManager
5
+ import android.content.Context
6
+ import android.graphics.Bitmap
7
+ import android.graphics.Color
8
+ import android.view.LayoutInflater
9
+ import android.widget.ImageView
10
+ import android.widget.TextView
11
+ import android.widget.Toast
12
+ import androidx.appcompat.app.AlertDialog
13
+ import androidx.appcompat.app.AppCompatActivity
14
+ import com.google.android.material.button.MaterialButton
15
+ import com.pakstr.app.R
16
+ import com.pakstr.app.signer.bunker.Nip46PairingQrCode
17
+
18
+ object BunkerPairingDialog {
19
+ fun show(
20
+ activity: AppCompatActivity,
21
+ pairingUri: String,
22
+ onShare: (String) -> Unit
23
+ ): AlertDialog {
24
+ val view = LayoutInflater.from(activity)
25
+ .inflate(R.layout.dialog_bunker_pairing, null, false)
26
+ val qrImage = view.findViewById<ImageView>(R.id.bunker_pairing_qr)
27
+ val uriText = view.findViewById<TextView>(R.id.bunker_pairing_uri)
28
+ val copy = view.findViewById<MaterialButton>(R.id.button_copy_pairing_uri)
29
+ val share = view.findViewById<MaterialButton>(R.id.button_share_pairing_uri)
30
+
31
+ uriText.text = pairingUri
32
+ qrImage.setImageBitmap(createQrBitmap(pairingUri, QR_CODE_SIZE_PIXELS))
33
+
34
+ copy.setOnClickListener {
35
+ val clipboard = activity.getSystemService(
36
+ Context.CLIPBOARD_SERVICE
37
+ ) as ClipboardManager
38
+ clipboard.setPrimaryClip(
39
+ ClipData.newPlainText(
40
+ activity.getString(R.string.signer_picker_pairing_clip_label),
41
+ pairingUri
42
+ )
43
+ )
44
+ Toast.makeText(
45
+ activity,
46
+ R.string.signer_picker_pairing_copied,
47
+ Toast.LENGTH_SHORT
48
+ ).show()
49
+ }
50
+ share.setOnClickListener {
51
+ onShare(pairingUri)
52
+ }
53
+
54
+ val dialog =
55
+ AlertDialog.Builder(activity)
56
+ .setView(view)
57
+ .setNegativeButton(android.R.string.cancel, null)
58
+ .create()
59
+ dialog.show()
60
+ return dialog
61
+ }
62
+
63
+ private fun createQrBitmap(
64
+ value: String,
65
+ sizePixels: Int
66
+ ): Bitmap {
67
+ val matrix = Nip46PairingQrCode.encode(value, sizePixels)
68
+ val pixels = IntArray(sizePixels * sizePixels) { index ->
69
+ val x = index % sizePixels
70
+ val y = index / sizePixels
71
+ if (matrix[x, y]) Color.BLACK else Color.WHITE
72
+ }
73
+ return Bitmap.createBitmap(
74
+ pixels,
75
+ sizePixels,
76
+ sizePixels,
77
+ Bitmap.Config.ARGB_8888
78
+ )
79
+ }
80
+
81
+ private const val QR_CODE_SIZE_PIXELS = 768
82
+ }
@@ -1,7 +1,5 @@
1
1
  package com.pakstr.app.utils
2
2
 
3
- import com.pakstr.app.R
4
-
5
3
  import android.os.Bundle
6
4
  import android.view.LayoutInflater
7
5
  import android.view.View
@@ -9,31 +7,94 @@ import android.view.ViewGroup
9
7
  import android.widget.Button
10
8
  import android.widget.RadioGroup
11
9
  import com.google.android.material.bottomsheet.BottomSheetDialogFragment
12
- import com.pakstr.app.signer.SignerType
10
+ import com.google.android.material.textfield.TextInputEditText
11
+ import com.google.android.material.textfield.TextInputLayout
12
+ import com.pakstr.app.R
13
13
 
14
- class SignerPickerBottomSheet (
15
- private val onSignerSelected: (SignerType) -> Unit
14
+ class SignerPickerBottomSheet(
15
+ private val hasBunkerSession: Boolean,
16
+ private val onAmberSelected: () -> Unit,
17
+ private val onBunkerSelected: (String) -> Boolean,
18
+ private val onSharePairing: (List<String>) -> Unit,
19
+ private val onBunkerLogout: () -> Unit
16
20
  ) : BottomSheetDialogFragment() {
17
21
 
18
22
  override fun onCreateView(
19
- inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
20
- ): View? {
21
- return inflater.inflate(R.layout.dialog_picker, container, false)
23
+ inflater: LayoutInflater,
24
+ container: ViewGroup?,
25
+ savedInstanceState: Bundle?
26
+ ): View {
27
+ return inflater.inflate(
28
+ R.layout.dialog_picker,
29
+ container,
30
+ false
31
+ )
22
32
  }
23
33
 
24
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
34
+ override fun onViewCreated(
35
+ view: View,
36
+ savedInstanceState: Bundle?
37
+ ) {
25
38
  super.onViewCreated(view, savedInstanceState)
26
-
27
39
  val radioGroup = view.findViewById<RadioGroup>(R.id.options_radio_group)
28
- val btnConfirm = view.findViewById<Button>(R.id.button_confirm)
40
+ val uriContainer = view.findViewById<TextInputLayout>(R.id.bunker_uri_container)
41
+ val uriInput = view.findViewById<TextInputEditText>(R.id.bunker_uri)
42
+ val confirm = view.findViewById<Button>(R.id.button_confirm)
43
+ val sharePairing = view.findViewById<Button>(R.id.button_share_pairing)
44
+ val logout = view.findViewById<Button>(R.id.button_logout_bunker)
45
+
46
+ logout.visibility =
47
+ if (hasBunkerSession) View.VISIBLE else View.GONE
48
+
49
+ fun updateBunkerControls() {
50
+ val bunkerSelected =
51
+ radioGroup.checkedRadioButtonId == R.id.radio_option_two
52
+ uriContainer.visibility =
53
+ if (bunkerSelected) View.VISIBLE else View.GONE
54
+ sharePairing.visibility =
55
+ if (bunkerSelected) View.VISIBLE else View.GONE
56
+ }
57
+ radioGroup.setOnCheckedChangeListener { _, _ ->
58
+ uriContainer.error = null
59
+ updateBunkerControls()
60
+ }
61
+ updateBunkerControls()
29
62
 
30
- btnConfirm.setOnClickListener {
31
- val selectedType = when (radioGroup.checkedRadioButtonId) {
32
- R.id.radio_option_one -> SignerType.AMBER
33
- R.id.radio_option_two -> SignerType.BUNKER
34
- else -> SignerType.AMBER
63
+ confirm.setOnClickListener {
64
+ if (radioGroup.checkedRadioButtonId == R.id.radio_option_two) {
65
+ val value = uriInput.text?.toString().orEmpty().trim()
66
+ if (!onBunkerSelected(value)) {
67
+ uriContainer.error =
68
+ getString(R.string.signer_picker_invalid_bunker)
69
+ return@setOnClickListener
70
+ }
71
+ } else {
72
+ onAmberSelected()
35
73
  }
36
- onSignerSelected(selectedType)
74
+ dismiss()
75
+ }
76
+
77
+ sharePairing.setOnClickListener {
78
+ val relays =
79
+ uriInput.text?.toString()
80
+ .orEmpty()
81
+ .lineSequence()
82
+ .map(String::trim)
83
+ .filter { it.startsWith("wss://") }
84
+ .toList()
85
+ .ifEmpty {
86
+ getString(R.string.signer_picker_pairing_relays_default)
87
+ .lineSequence()
88
+ .map(String::trim)
89
+ .filter { it.startsWith("wss://") }
90
+ .toList()
91
+ }
92
+ onSharePairing(relays)
93
+ dismiss()
94
+ }
95
+
96
+ logout.setOnClickListener {
97
+ onBunkerLogout()
37
98
  dismiss()
38
99
  }
39
100
  }
@@ -1,5 +1,6 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <com.pakstr.app.debug.SupportUnlockGestureLayout xmlns:android="http://schemas.android.com/apk/res/android"
3
+ xmlns:app="http://schemas.android.com/apk/res-auto"
3
4
  android:id="@+id/mainContainer"
4
5
  android:layout_width="match_parent"
5
6
  android:layout_height="match_parent"
@@ -18,6 +19,16 @@
18
19
  android:layout_gravity="center"
19
20
  android:indeterminateDrawable="@drawable/custom_loader" />
20
21
 
22
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
23
+ android:id="@+id/signerSettingsFab"
24
+ android:layout_width="wrap_content"
25
+ android:layout_height="wrap_content"
26
+ android:layout_gravity="top|end"
27
+ android:layout_margin="24dp"
28
+ android:contentDescription="@string/signer_settings"
29
+ android:src="@android:drawable/ic_menu_manage"
30
+ app:fabSize="mini" />
31
+
21
32
  <com.google.android.material.floatingactionbutton.FloatingActionButton
22
33
  android:id="@+id/developerToolsFab"
23
34
  android:layout_width="wrap_content"
@@ -0,0 +1,75 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
3
+ xmlns:app="http://schemas.android.com/apk/res-auto"
4
+ android:layout_width="match_parent"
5
+ android:layout_height="wrap_content"
6
+ android:fillViewport="true">
7
+
8
+ <LinearLayout
9
+ android:layout_width="match_parent"
10
+ android:layout_height="wrap_content"
11
+ android:orientation="vertical"
12
+ android:padding="24dp">
13
+
14
+ <TextView
15
+ android:layout_width="wrap_content"
16
+ android:layout_height="wrap_content"
17
+ android:text="@string/signer_picker_pairing_qr_title"
18
+ android:textColor="#1F2937"
19
+ android:textSize="20sp"
20
+ android:textStyle="bold" />
21
+
22
+ <TextView
23
+ android:layout_width="wrap_content"
24
+ android:layout_height="wrap_content"
25
+ android:layout_marginTop="8dp"
26
+ android:text="@string/signer_picker_pairing_qr_instructions"
27
+ android:textColor="#4B5563"
28
+ android:textSize="14sp" />
29
+
30
+ <ImageView
31
+ android:id="@+id/bunker_pairing_qr"
32
+ android:layout_width="280dp"
33
+ android:layout_height="280dp"
34
+ android:layout_gravity="center_horizontal"
35
+ android:layout_marginTop="20dp"
36
+ android:adjustViewBounds="true"
37
+ android:contentDescription="@string/signer_picker_pairing_qr_content_description"
38
+ android:importantForAccessibility="yes"
39
+ android:scaleType="fitCenter" />
40
+
41
+ <TextView
42
+ android:id="@+id/bunker_pairing_uri"
43
+ android:layout_width="match_parent"
44
+ android:layout_height="wrap_content"
45
+ android:layout_marginTop="16dp"
46
+ android:background="#F3F4F6"
47
+ android:ellipsize="end"
48
+ android:fontFamily="monospace"
49
+ android:maxLines="4"
50
+ android:padding="12dp"
51
+ android:textColor="#1F2937"
52
+ android:textIsSelectable="true"
53
+ android:textSize="12sp" />
54
+
55
+ <com.google.android.material.button.MaterialButton
56
+ android:id="@+id/button_copy_pairing_uri"
57
+ style="@style/Widget.MaterialComponents.Button.OutlinedButton"
58
+ android:layout_width="match_parent"
59
+ android:layout_height="48dp"
60
+ android:layout_marginTop="16dp"
61
+ android:text="@string/signer_picker_pairing_copy"
62
+ android:textAllCaps="false" />
63
+
64
+ <com.google.android.material.button.MaterialButton
65
+ android:id="@+id/button_share_pairing_uri"
66
+ android:layout_width="match_parent"
67
+ android:layout_height="48dp"
68
+ android:layout_marginTop="8dp"
69
+ android:text="@string/signer_picker_pairing_share"
70
+ android:textAllCaps="false"
71
+ android:textColor="@android:color/white"
72
+ app:backgroundTint="#2563EB" />
73
+
74
+ </LinearLayout>
75
+ </ScrollView>