pakstr 0.27.0 → 0.29.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.
- package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +266 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/signer/local/LocalAccountStoreTest.kt +272 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/utils/SignerPickerBottomSheetTest.kt +165 -0
- package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +211 -11
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip19Codec.kt +181 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/LocalSigner.kt +100 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +6 -6
- package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +135 -3
- package/android-template/app/src/main/java/com/pakstr/app/signer/SignerType.kt +1 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountImportCandidate.kt +42 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountStore.kt +260 -0
- package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +98 -12
- package/android-template/app/src/main/res/layout/activity_developer_tools.xml +15 -0
- package/android-template/app/src/main/res/layout/dialog_picker.xml +80 -0
- package/android-template/app/src/main/res/values/strings.xml +16 -1
- package/android-template/app/src/main/res/xml/backup_rules.xml +4 -11
- package/android-template/app/src/main/res/xml/data_extraction_rules.xml +7 -13
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip19CodecTest.kt +178 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/LocalSignerTest.kt +110 -0
- package/package.json +3 -3
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
package com.pakstr.app.utils
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.test.core.app.ActivityScenario
|
|
5
|
+
import androidx.test.core.app.ApplicationProvider
|
|
6
|
+
import androidx.test.espresso.Espresso.onView
|
|
7
|
+
import androidx.test.espresso.action.ViewActions.click
|
|
8
|
+
import androidx.test.espresso.action.ViewActions.replaceText
|
|
9
|
+
import androidx.test.espresso.assertion.ViewAssertions.matches
|
|
10
|
+
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
|
11
|
+
import androidx.test.espresso.matcher.ViewMatchers.isChecked
|
|
12
|
+
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
|
|
13
|
+
import androidx.test.espresso.matcher.ViewMatchers.withId
|
|
14
|
+
import androidx.test.espresso.matcher.ViewMatchers.Visibility.GONE
|
|
15
|
+
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
16
|
+
import com.pakstr.app.MainActivity
|
|
17
|
+
import com.pakstr.app.R
|
|
18
|
+
import com.pakstr.app.signer.SignerManager
|
|
19
|
+
import com.pakstr.app.signer.SignerType
|
|
20
|
+
import com.pakstr.app.signer.local.LocalAccountStore
|
|
21
|
+
import org.junit.After
|
|
22
|
+
import org.junit.Assert.assertEquals
|
|
23
|
+
import org.junit.Assert.assertNull
|
|
24
|
+
import org.junit.Assert.assertTrue
|
|
25
|
+
import org.junit.Before
|
|
26
|
+
import org.junit.Test
|
|
27
|
+
import org.junit.runner.RunWith
|
|
28
|
+
import java.security.KeyStore
|
|
29
|
+
|
|
30
|
+
@RunWith(AndroidJUnit4::class)
|
|
31
|
+
class SignerPickerBottomSheetTest {
|
|
32
|
+
private lateinit var context: Context
|
|
33
|
+
|
|
34
|
+
@Before
|
|
35
|
+
fun setup() {
|
|
36
|
+
context = ApplicationProvider.getApplicationContext()
|
|
37
|
+
AppPreferences.init(context)
|
|
38
|
+
AppPreferences.saveBoolean("isFirstRun", false)
|
|
39
|
+
clearLocalAccount()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@After
|
|
43
|
+
fun teardown() {
|
|
44
|
+
clearLocalAccount()
|
|
45
|
+
context.getSharedPreferences("signer", Context.MODE_PRIVATE)
|
|
46
|
+
.edit()
|
|
47
|
+
.clear()
|
|
48
|
+
.commit()
|
|
49
|
+
AppPreferences.saveBoolean("isFirstRun", true)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Test
|
|
53
|
+
fun cold_start_does_not_show_signer_picker() {
|
|
54
|
+
AppPreferences.saveBoolean("isFirstRun", true)
|
|
55
|
+
|
|
56
|
+
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
|
57
|
+
scenario.onActivity { activity ->
|
|
58
|
+
assertNull(
|
|
59
|
+
activity.supportFragmentManager
|
|
60
|
+
.findFragmentByTag("SignerPicker")
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Test
|
|
67
|
+
fun completed_legacy_setup_migrates_signer_configuration() {
|
|
68
|
+
AppPreferences.saveBoolean("isFirstRun", false)
|
|
69
|
+
|
|
70
|
+
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
|
71
|
+
scenario.onActivity { activity ->
|
|
72
|
+
assertTrue(signerManager(activity).isSignerConfigured())
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Test
|
|
78
|
+
fun local_option_creates_and_selects_local_signer() {
|
|
79
|
+
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
|
80
|
+
showPicker(scenario)
|
|
81
|
+
|
|
82
|
+
onView(withId(R.id.radio_option_three))
|
|
83
|
+
.check(matches(isDisplayed()))
|
|
84
|
+
.perform(click())
|
|
85
|
+
onView(withId(R.id.radio_local_create))
|
|
86
|
+
.check(matches(isChecked()))
|
|
87
|
+
onView(withId(R.id.bunker_uri_container))
|
|
88
|
+
.check(matches(withEffectiveVisibility(GONE)))
|
|
89
|
+
onView(withId(R.id.button_share_pairing))
|
|
90
|
+
.check(matches(withEffectiveVisibility(GONE)))
|
|
91
|
+
onView(withId(R.id.button_confirm)).perform(click())
|
|
92
|
+
|
|
93
|
+
waitForLocalSelection(scenario)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Test
|
|
98
|
+
fun local_import_can_be_cancelled_before_persistence() {
|
|
99
|
+
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
|
100
|
+
showPicker(scenario)
|
|
101
|
+
|
|
102
|
+
onView(withId(R.id.radio_option_three)).perform(click())
|
|
103
|
+
onView(withId(R.id.radio_local_import)).perform(click())
|
|
104
|
+
onView(withId(R.id.local_nsec_container))
|
|
105
|
+
.check(matches(isDisplayed()))
|
|
106
|
+
onView(withId(R.id.local_nsec)).perform(replaceText(NSEC_ONE))
|
|
107
|
+
onView(withId(R.id.button_confirm)).perform(click())
|
|
108
|
+
onView(withId(android.R.id.button2)).perform(click())
|
|
109
|
+
|
|
110
|
+
scenario.onActivity { activity ->
|
|
111
|
+
assertTrue(!signerManager(activity).hasLocalAccount())
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private fun showPicker(
|
|
117
|
+
scenario: ActivityScenario<MainActivity>
|
|
118
|
+
) {
|
|
119
|
+
scenario.onActivity { activity ->
|
|
120
|
+
MainActivity::class.java
|
|
121
|
+
.getDeclaredMethod("showSignerPicker")
|
|
122
|
+
.apply { isAccessible = true }
|
|
123
|
+
.invoke(activity)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun waitForLocalSelection(
|
|
128
|
+
scenario: ActivityScenario<MainActivity>
|
|
129
|
+
) {
|
|
130
|
+
repeat(50) {
|
|
131
|
+
var activeType: SignerType? = null
|
|
132
|
+
scenario.onActivity { activity ->
|
|
133
|
+
activeType = signerManager(activity).getActiveType()
|
|
134
|
+
}
|
|
135
|
+
if (activeType == SignerType.LOCAL) {
|
|
136
|
+
assertEquals(SignerType.LOCAL, activeType)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
Thread.sleep(100)
|
|
140
|
+
}
|
|
141
|
+
throw AssertionError("LOCAL signer was not selected")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private fun signerManager(activity: MainActivity): SignerManager {
|
|
145
|
+
return MainActivity::class.java
|
|
146
|
+
.getDeclaredField("signerManager")
|
|
147
|
+
.apply { isAccessible = true }
|
|
148
|
+
.get(activity) as SignerManager
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private fun clearLocalAccount() {
|
|
152
|
+
context.getSharedPreferences(
|
|
153
|
+
LocalAccountStore.PREFERENCES_NAME,
|
|
154
|
+
Context.MODE_PRIVATE
|
|
155
|
+
).edit().clear().commit()
|
|
156
|
+
KeyStore.getInstance("AndroidKeyStore")
|
|
157
|
+
.apply { load(null) }
|
|
158
|
+
.deleteEntry(LocalAccountStore.KEY_ALIAS)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private companion object {
|
|
162
|
+
const val NSEC_ONE =
|
|
163
|
+
"nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -9,6 +9,7 @@ import android.os.Handler
|
|
|
9
9
|
import android.os.Looper
|
|
10
10
|
|
|
11
11
|
import android.view.View
|
|
12
|
+
import android.view.WindowManager
|
|
12
13
|
import android.webkit.PermissionRequest
|
|
13
14
|
import android.webkit.WebView
|
|
14
15
|
import android.widget.FrameLayout
|
|
@@ -41,8 +42,11 @@ import com.pakstr.app.permissions.interfaces.PermissionHandler
|
|
|
41
42
|
|
|
42
43
|
import com.pakstr.app.signer.SignerManager
|
|
43
44
|
import com.pakstr.app.signer.SignerType
|
|
45
|
+
import com.pakstr.app.signer.local.LocalAccountImportCandidate
|
|
44
46
|
import kotlinx.coroutines.CancellationException
|
|
47
|
+
import kotlinx.coroutines.Dispatchers
|
|
45
48
|
import kotlinx.coroutines.launch
|
|
49
|
+
import kotlinx.coroutines.withContext
|
|
46
50
|
import com.pakstr.app.utils.AppPreferences
|
|
47
51
|
import com.pakstr.app.utils.BunkerPairingDialog
|
|
48
52
|
import com.pakstr.app.utils.SignerPickerBottomSheet
|
|
@@ -61,6 +65,9 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
61
65
|
private lateinit var fab: FloatingActionButton
|
|
62
66
|
private lateinit var supportUnlockGestureLayout: SupportUnlockGestureLayout
|
|
63
67
|
private var bunkerPairingDialog: AlertDialog? = null
|
|
68
|
+
private var localImportDialog: AlertDialog? = null
|
|
69
|
+
private var localImportCandidate: LocalAccountImportCandidate? = null
|
|
70
|
+
private var signerSetupInProgress = false
|
|
64
71
|
|
|
65
72
|
private val diagnosticsHandler = Handler(Looper.getMainLooper())
|
|
66
73
|
private val diagnosticsExpiryCheck = Runnable {
|
|
@@ -128,12 +135,6 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
initSigner()
|
|
131
|
-
if (
|
|
132
|
-
savedInstanceState == null &&
|
|
133
|
-
AppPreferences.getBoolean("isFirstRun", true)
|
|
134
|
-
) {
|
|
135
|
-
container.post(::showSignerPicker)
|
|
136
|
-
}
|
|
137
138
|
|
|
138
139
|
ViewCompat.setOnApplyWindowInsetsListener(
|
|
139
140
|
|
|
@@ -269,8 +270,26 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
269
270
|
SignerManager(
|
|
270
271
|
this,
|
|
271
272
|
amberLauncher,
|
|
272
|
-
bunkerAuthChallengeHandler = ::openBunkerAuthChallenge
|
|
273
|
+
bunkerAuthChallengeHandler = ::openBunkerAuthChallenge,
|
|
274
|
+
signerPickerRequestHandler = ::requestSignerPicker
|
|
273
275
|
)
|
|
276
|
+
signerManager.migrateLegacySignerConfiguration(
|
|
277
|
+
setupCompleted = !AppPreferences.getBoolean("isFirstRun", true)
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private fun requestSignerPicker() {
|
|
282
|
+
runOnUiThread {
|
|
283
|
+
if (
|
|
284
|
+
isFinishing ||
|
|
285
|
+
isDestroyed ||
|
|
286
|
+
supportFragmentManager.isStateSaved
|
|
287
|
+
) {
|
|
288
|
+
signerManager.cancelSignerSelection()
|
|
289
|
+
return@runOnUiThread
|
|
290
|
+
}
|
|
291
|
+
showSignerPicker()
|
|
292
|
+
}
|
|
274
293
|
}
|
|
275
294
|
|
|
276
295
|
private fun openBunkerAuthChallenge(value: String) {
|
|
@@ -360,6 +379,11 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
360
379
|
|
|
361
380
|
bunkerPairingDialog?.dismiss()
|
|
362
381
|
bunkerPairingDialog = null
|
|
382
|
+
localImportDialog?.setOnDismissListener(null)
|
|
383
|
+
localImportDialog?.dismiss()
|
|
384
|
+
localImportDialog = null
|
|
385
|
+
localImportCandidate?.close()
|
|
386
|
+
localImportCandidate = null
|
|
363
387
|
|
|
364
388
|
if (::signerManager.isInitialized) {
|
|
365
389
|
signerManager.close()
|
|
@@ -408,8 +432,26 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
408
432
|
}
|
|
409
433
|
|
|
410
434
|
private fun showSignerPicker() {
|
|
435
|
+
if (
|
|
436
|
+
supportFragmentManager.findFragmentByTag(SIGNER_PICKER_TAG) != null
|
|
437
|
+
) {
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
val hasLocalAccount =
|
|
441
|
+
try {
|
|
442
|
+
signerManager.hasLocalAccount()
|
|
443
|
+
} catch (_: Exception) {
|
|
444
|
+
Toast.makeText(
|
|
445
|
+
this,
|
|
446
|
+
R.string.signer_picker_local_status_failed,
|
|
447
|
+
Toast.LENGTH_LONG
|
|
448
|
+
).show()
|
|
449
|
+
signerManager.cancelSignerSelection()
|
|
450
|
+
return
|
|
451
|
+
}
|
|
411
452
|
SignerPickerBottomSheet(
|
|
412
453
|
hasBunkerSession = signerManager.hasBunkerSession(),
|
|
454
|
+
hasLocalAccount = hasLocalAccount,
|
|
413
455
|
onAmberSelected = {
|
|
414
456
|
signerManager.setActiveType(SignerType.AMBER)
|
|
415
457
|
handleSignerSetup()
|
|
@@ -423,7 +465,31 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
423
465
|
false
|
|
424
466
|
}
|
|
425
467
|
},
|
|
426
|
-
|
|
468
|
+
onLocalSelected = {
|
|
469
|
+
signerSetupInProgress = true
|
|
470
|
+
createLocalAccount()
|
|
471
|
+
},
|
|
472
|
+
onLocalImportSelected = { nsec ->
|
|
473
|
+
val candidate =
|
|
474
|
+
try {
|
|
475
|
+
signerManager.prepareLocalAccountImport(nsec)
|
|
476
|
+
} catch (_: Exception) {
|
|
477
|
+
null
|
|
478
|
+
}
|
|
479
|
+
if (candidate == null) {
|
|
480
|
+
false
|
|
481
|
+
} else {
|
|
482
|
+
signerSetupInProgress = true
|
|
483
|
+
localImportCandidate?.close()
|
|
484
|
+
localImportCandidate = candidate
|
|
485
|
+
showLocalImportConfirmation(candidate)
|
|
486
|
+
true
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
onSharePairing = { relayUrls ->
|
|
490
|
+
signerSetupInProgress = true
|
|
491
|
+
shareBunkerPairing(relayUrls)
|
|
492
|
+
},
|
|
427
493
|
onBunkerLogout = {
|
|
428
494
|
signerManager.logoutBunker()
|
|
429
495
|
Toast.makeText(
|
|
@@ -431,13 +497,130 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
431
497
|
R.string.signer_picker_disconnected,
|
|
432
498
|
Toast.LENGTH_SHORT
|
|
433
499
|
).show()
|
|
500
|
+
},
|
|
501
|
+
onDismissed = {
|
|
502
|
+
if (!signerSetupInProgress) {
|
|
503
|
+
signerManager.cancelSignerSelection()
|
|
504
|
+
}
|
|
434
505
|
}
|
|
435
506
|
).show(
|
|
436
507
|
supportFragmentManager,
|
|
437
|
-
|
|
508
|
+
SIGNER_PICKER_TAG
|
|
438
509
|
)
|
|
439
510
|
}
|
|
440
511
|
|
|
512
|
+
private fun createLocalAccount() {
|
|
513
|
+
lifecycleScope.launch {
|
|
514
|
+
val created =
|
|
515
|
+
withContext(Dispatchers.IO) {
|
|
516
|
+
try {
|
|
517
|
+
signerManager.createLocalAccount()
|
|
518
|
+
true
|
|
519
|
+
} catch (cancelled: CancellationException) {
|
|
520
|
+
throw cancelled
|
|
521
|
+
} catch (_: Exception) {
|
|
522
|
+
false
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
signerSetupInProgress = false
|
|
526
|
+
if (created) {
|
|
527
|
+
handleSignerSetup()
|
|
528
|
+
} else {
|
|
529
|
+
signerManager.cancelSignerSelection()
|
|
530
|
+
Toast.makeText(
|
|
531
|
+
this@MainActivity,
|
|
532
|
+
R.string.signer_picker_local_failed,
|
|
533
|
+
Toast.LENGTH_LONG
|
|
534
|
+
).show()
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private fun showLocalImportConfirmation(
|
|
540
|
+
candidate: LocalAccountImportCandidate
|
|
541
|
+
) {
|
|
542
|
+
if (localImportCandidate !== candidate) {
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
if (isFinishing || isDestroyed) {
|
|
546
|
+
discardLocalAccountImport()
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
var confirmed = false
|
|
550
|
+
val dialog =
|
|
551
|
+
AlertDialog.Builder(this)
|
|
552
|
+
.setTitle(R.string.signer_picker_local_confirm_title)
|
|
553
|
+
.setMessage(
|
|
554
|
+
getString(
|
|
555
|
+
R.string.signer_picker_local_confirm_message,
|
|
556
|
+
candidate.npub
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
.setPositiveButton(
|
|
560
|
+
R.string.signer_picker_local_confirm
|
|
561
|
+
) { _, _ ->
|
|
562
|
+
confirmed = true
|
|
563
|
+
confirmLocalAccountImport(candidate)
|
|
564
|
+
}
|
|
565
|
+
.setNegativeButton(
|
|
566
|
+
R.string.signer_picker_local_cancel,
|
|
567
|
+
null
|
|
568
|
+
)
|
|
569
|
+
.create()
|
|
570
|
+
dialog.setOnDismissListener {
|
|
571
|
+
if (!confirmed) {
|
|
572
|
+
discardLocalAccountImport()
|
|
573
|
+
}
|
|
574
|
+
if (localImportDialog === dialog) {
|
|
575
|
+
localImportDialog = null
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
localImportDialog = dialog
|
|
579
|
+
dialog.show()
|
|
580
|
+
dialog.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private fun confirmLocalAccountImport(
|
|
584
|
+
candidate: LocalAccountImportCandidate
|
|
585
|
+
) {
|
|
586
|
+
if (localImportCandidate !== candidate) {
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
localImportCandidate = null
|
|
590
|
+
lifecycleScope.launch {
|
|
591
|
+
val imported =
|
|
592
|
+
withContext(Dispatchers.IO) {
|
|
593
|
+
try {
|
|
594
|
+
signerManager.importLocalAccount(candidate)
|
|
595
|
+
true
|
|
596
|
+
} catch (cancelled: CancellationException) {
|
|
597
|
+
candidate.close()
|
|
598
|
+
throw cancelled
|
|
599
|
+
} catch (_: Exception) {
|
|
600
|
+
false
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
signerSetupInProgress = false
|
|
604
|
+
if (imported) {
|
|
605
|
+
handleSignerSetup()
|
|
606
|
+
} else {
|
|
607
|
+
signerManager.cancelSignerSelection()
|
|
608
|
+
Toast.makeText(
|
|
609
|
+
this@MainActivity,
|
|
610
|
+
R.string.signer_picker_local_import_failed,
|
|
611
|
+
Toast.LENGTH_LONG
|
|
612
|
+
).show()
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private fun discardLocalAccountImport() {
|
|
618
|
+
localImportCandidate?.close()
|
|
619
|
+
localImportCandidate = null
|
|
620
|
+
signerSetupInProgress = false
|
|
621
|
+
signerManager.cancelSignerSelection()
|
|
622
|
+
}
|
|
623
|
+
|
|
441
624
|
private fun shareBunkerPairing(relayUrls: List<String>) {
|
|
442
625
|
lifecycleScope.launch {
|
|
443
626
|
val pairingUri =
|
|
@@ -446,6 +629,8 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
446
629
|
} catch (cancelled: CancellationException) {
|
|
447
630
|
throw cancelled
|
|
448
631
|
} catch (_: Exception) {
|
|
632
|
+
signerSetupInProgress = false
|
|
633
|
+
signerManager.cancelSignerSelection()
|
|
449
634
|
Toast.makeText(
|
|
450
635
|
this@MainActivity,
|
|
451
636
|
R.string.signer_picker_connection_failed,
|
|
@@ -469,7 +654,18 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
469
654
|
R.string.signer_picker_pairing_waiting,
|
|
470
655
|
Toast.LENGTH_LONG
|
|
471
656
|
).show()
|
|
472
|
-
val connected =
|
|
657
|
+
val connected =
|
|
658
|
+
try {
|
|
659
|
+
signerManager.finishBunkerPairing()
|
|
660
|
+
} catch (cancelled: CancellationException) {
|
|
661
|
+
throw cancelled
|
|
662
|
+
} catch (_: Exception) {
|
|
663
|
+
false
|
|
664
|
+
}
|
|
665
|
+
signerSetupInProgress = false
|
|
666
|
+
if (!connected) {
|
|
667
|
+
signerManager.cancelSignerSelection()
|
|
668
|
+
}
|
|
473
669
|
if (pairingDialog.isShowing) {
|
|
474
670
|
pairingDialog.dismiss()
|
|
475
671
|
}
|
|
@@ -510,4 +706,8 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
|
|
|
510
706
|
DebugReportShare.share(this)
|
|
511
707
|
|
|
512
708
|
}
|
|
513
|
-
|
|
709
|
+
|
|
710
|
+
private companion object {
|
|
711
|
+
const val SIGNER_PICKER_TAG = "SignerPicker"
|
|
712
|
+
}
|
|
713
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
package com.pakstr.app.crypto
|
|
2
|
+
|
|
3
|
+
internal class InvalidNsecException : IllegalArgumentException(
|
|
4
|
+
"Invalid nsec"
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
internal object Nip19Codec {
|
|
8
|
+
private const val NSEC_HRP = "nsec"
|
|
9
|
+
private const val NPUB_HRP = "npub"
|
|
10
|
+
private const val PAYLOAD_SIZE = 32
|
|
11
|
+
private const val CHECKSUM_SIZE = 6
|
|
12
|
+
private const val MAX_ENCODED_LENGTH = 90
|
|
13
|
+
private const val BECH32_CONSTANT = 1
|
|
14
|
+
private const val CHARSET =
|
|
15
|
+
"qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
16
|
+
private val reverseCharset = IntArray(128) { -1 }.apply {
|
|
17
|
+
CHARSET.forEachIndexed { index, character ->
|
|
18
|
+
this[character.code] = index
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fun decodeNsec(value: String): ByteArray {
|
|
23
|
+
if (
|
|
24
|
+
value.isEmpty() ||
|
|
25
|
+
value.length > MAX_ENCODED_LENGTH ||
|
|
26
|
+
value != value.lowercase()
|
|
27
|
+
) {
|
|
28
|
+
throw InvalidNsecException()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
val separator = value.lastIndexOf('1')
|
|
32
|
+
if (
|
|
33
|
+
separator < 1 ||
|
|
34
|
+
separator + CHECKSUM_SIZE + 1 > value.length
|
|
35
|
+
) {
|
|
36
|
+
throw InvalidNsecException()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
val hrp = value.substring(0, separator)
|
|
40
|
+
if (
|
|
41
|
+
hrp != NSEC_HRP ||
|
|
42
|
+
hrp.any { it.code !in 33..126 }
|
|
43
|
+
) {
|
|
44
|
+
throw InvalidNsecException()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
val data = IntArray(value.length - separator - 1)
|
|
48
|
+
value.substring(separator + 1).forEachIndexed { index, character ->
|
|
49
|
+
val decoded =
|
|
50
|
+
if (character.code < reverseCharset.size) {
|
|
51
|
+
reverseCharset[character.code]
|
|
52
|
+
} else {
|
|
53
|
+
-1
|
|
54
|
+
}
|
|
55
|
+
if (decoded < 0) {
|
|
56
|
+
throw InvalidNsecException()
|
|
57
|
+
}
|
|
58
|
+
data[index] = decoded
|
|
59
|
+
}
|
|
60
|
+
if (polymod(hrpExpand(hrp) + data) != BECH32_CONSTANT) {
|
|
61
|
+
throw InvalidNsecException()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
val decoded = convertBits(
|
|
65
|
+
data.copyOf(data.size - CHECKSUM_SIZE),
|
|
66
|
+
fromBits = 5,
|
|
67
|
+
toBits = 8,
|
|
68
|
+
pad = false
|
|
69
|
+
)
|
|
70
|
+
if (decoded.size != PAYLOAD_SIZE) {
|
|
71
|
+
decoded.fill(0)
|
|
72
|
+
throw InvalidNsecException()
|
|
73
|
+
}
|
|
74
|
+
return ByteArray(decoded.size) { index ->
|
|
75
|
+
decoded[index].toByte()
|
|
76
|
+
}.also {
|
|
77
|
+
decoded.fill(0)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fun encodeNpub(publicKey: ByteArray): String {
|
|
82
|
+
require(publicKey.size == PAYLOAD_SIZE) {
|
|
83
|
+
"Public key must be exactly 32 bytes"
|
|
84
|
+
}
|
|
85
|
+
val data = convertBits(
|
|
86
|
+
IntArray(publicKey.size) { index ->
|
|
87
|
+
publicKey[index].toInt() and 0xff
|
|
88
|
+
},
|
|
89
|
+
fromBits = 8,
|
|
90
|
+
toBits = 5,
|
|
91
|
+
pad = true
|
|
92
|
+
)
|
|
93
|
+
val checksum = createChecksum(NPUB_HRP, data)
|
|
94
|
+
return buildString {
|
|
95
|
+
append(NPUB_HRP)
|
|
96
|
+
append('1')
|
|
97
|
+
(data + checksum).forEach { append(CHARSET[it]) }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private fun createChecksum(
|
|
102
|
+
hrp: String,
|
|
103
|
+
data: IntArray
|
|
104
|
+
): IntArray {
|
|
105
|
+
val values = hrpExpand(hrp) + data + IntArray(CHECKSUM_SIZE)
|
|
106
|
+
val remainder = polymod(values) xor BECH32_CONSTANT
|
|
107
|
+
return IntArray(CHECKSUM_SIZE) { index ->
|
|
108
|
+
(remainder shr (5 * (CHECKSUM_SIZE - 1 - index))) and 31
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private fun hrpExpand(hrp: String): IntArray {
|
|
113
|
+
val result = IntArray(hrp.length * 2 + 1)
|
|
114
|
+
hrp.forEachIndexed { index, character ->
|
|
115
|
+
result[index] = character.code shr 5
|
|
116
|
+
result[hrp.length + 1 + index] = character.code and 31
|
|
117
|
+
}
|
|
118
|
+
return result
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private fun polymod(values: IntArray): Int {
|
|
122
|
+
var checksum = 1
|
|
123
|
+
val generators = intArrayOf(
|
|
124
|
+
0x3b6a57b2,
|
|
125
|
+
0x26508e6d,
|
|
126
|
+
0x1ea119fa,
|
|
127
|
+
0x3d4233dd,
|
|
128
|
+
0x2a1462b3
|
|
129
|
+
)
|
|
130
|
+
values.forEach { value ->
|
|
131
|
+
val top = checksum ushr 25
|
|
132
|
+
checksum = (checksum and 0x1ffffff) shl 5 xor value
|
|
133
|
+
generators.forEachIndexed { index, generator ->
|
|
134
|
+
if ((top shr index) and 1 != 0) {
|
|
135
|
+
checksum = checksum xor generator
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return checksum
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private fun convertBits(
|
|
143
|
+
values: IntArray,
|
|
144
|
+
fromBits: Int,
|
|
145
|
+
toBits: Int,
|
|
146
|
+
pad: Boolean
|
|
147
|
+
): IntArray {
|
|
148
|
+
var accumulator = 0
|
|
149
|
+
var bitCount = 0
|
|
150
|
+
val output = ArrayList<Int>()
|
|
151
|
+
val fromMask = (1 shl fromBits) - 1
|
|
152
|
+
val toMask = (1 shl toBits) - 1
|
|
153
|
+
|
|
154
|
+
values.forEach { value ->
|
|
155
|
+
if (value < 0 || value ushr fromBits != 0) {
|
|
156
|
+
throw InvalidNsecException()
|
|
157
|
+
}
|
|
158
|
+
accumulator =
|
|
159
|
+
((accumulator and ((1 shl (fromBits + toBits - 1)) - 1))
|
|
160
|
+
shl fromBits) or (value and fromMask)
|
|
161
|
+
bitCount += fromBits
|
|
162
|
+
while (bitCount >= toBits) {
|
|
163
|
+
bitCount -= toBits
|
|
164
|
+
output += (accumulator ushr bitCount) and toMask
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (pad) {
|
|
169
|
+
if (bitCount > 0) {
|
|
170
|
+
output += (accumulator shl (toBits - bitCount)) and toMask
|
|
171
|
+
}
|
|
172
|
+
} else if (
|
|
173
|
+
bitCount >= fromBits ||
|
|
174
|
+
((accumulator shl (toBits - bitCount)) and toMask) != 0
|
|
175
|
+
) {
|
|
176
|
+
throw InvalidNsecException()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return output.toIntArray()
|
|
180
|
+
}
|
|
181
|
+
}
|