pakstr 0.26.0 → 0.28.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.
@@ -11,8 +11,14 @@ import com.pakstr.app.crypto.Nip46ClientKey
11
11
  import com.pakstr.app.signer.bunker.BunkerConfig
12
12
  import com.pakstr.app.signer.bunker.BunkerConnectionStore
13
13
  import com.pakstr.app.signer.bunker.Nip46Client
14
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
14
15
  import com.pakstr.app.signer.interfaces.NostrSigner
16
+ import com.pakstr.app.signer.local.LocalAccountStore
17
+ import kotlinx.coroutines.CoroutineStart
18
+ import kotlinx.coroutines.async
15
19
  import kotlinx.coroutines.runBlocking
20
+ import java.security.KeyStore
21
+ import org.junit.After
16
22
  import org.junit.Assert.assertEquals
17
23
  import org.junit.Assert.assertFalse
18
24
  import org.junit.Assert.assertNull
@@ -24,6 +30,146 @@ import org.junit.runner.RunWith
24
30
  @RunWith(AndroidJUnit4::class)
25
31
  class SignerManagerTest {
26
32
 
33
+ private val context
34
+ get() = ApplicationProvider.getApplicationContext<Context>()
35
+
36
+ @After
37
+ fun clearSignerPreferences() {
38
+ context.getSharedPreferences("signer", Context.MODE_PRIVATE)
39
+ .edit()
40
+ .clear()
41
+ .commit()
42
+ }
43
+
44
+ @Test
45
+ fun fresh_manager_defaults_to_amber_but_is_not_configured() {
46
+ clearSignerPreferences()
47
+ val manager = SignerManager(context, NoOpLauncher())
48
+
49
+ assertEquals(SignerType.AMBER, manager.getActiveType())
50
+ assertFalse(manager.isSignerConfigured())
51
+ }
52
+
53
+ @Test
54
+ fun explicit_selection_persists_configuration_across_recreation() =
55
+ runBlocking {
56
+ clearSignerPreferences()
57
+ var pickerRequests = 0
58
+ val first = SignerManager(context, NoOpLauncher())
59
+ first.setActiveType(SignerType.FAKE)
60
+ val restored =
61
+ SignerManager(
62
+ context,
63
+ NoOpLauncher(),
64
+ signerPickerRequestHandler = { pickerRequests++ }
65
+ )
66
+
67
+ assertTrue(restored.isSignerConfigured())
68
+ assertEquals(SignerType.FAKE, restored.getActiveType())
69
+ assertTrue(restored.getConfiguredSigner() is FakeSigner)
70
+ assertEquals(0, pickerRequests)
71
+ }
72
+
73
+ @Test
74
+ fun local_account_recreation_reuses_existing_identity() = runBlocking {
75
+ clearLocalAccount(context)
76
+ val first = SignerManager(context, NoOpLauncher())
77
+ try {
78
+ first.createLocalAccount()
79
+ val firstPublicKey = first.getCurrentUserPubkey()
80
+ val restored = SignerManager(context, NoOpLauncher())
81
+ try {
82
+ assertTrue(restored.isSignerConfigured())
83
+ assertEquals(SignerType.LOCAL, restored.getActiveType())
84
+ assertEquals(firstPublicKey, restored.getCurrentUserPubkey())
85
+ } finally {
86
+ restored.close()
87
+ }
88
+ } finally {
89
+ first.close()
90
+ clearLocalAccount(context)
91
+ }
92
+ }
93
+
94
+ @Test
95
+ fun legacy_completed_setup_migrates_configuration() {
96
+ clearSignerPreferences()
97
+ val manager = SignerManager(context, NoOpLauncher())
98
+
99
+ manager.migrateLegacySignerConfiguration(setupCompleted = true)
100
+
101
+ assertTrue(manager.isSignerConfigured())
102
+ assertEquals(SignerType.AMBER, manager.getActiveType())
103
+ }
104
+
105
+ @Test
106
+ fun configured_signer_waits_for_one_picker_and_resumes_all_requests() =
107
+ runBlocking {
108
+ clearSignerPreferences()
109
+ var pickerRequests = 0
110
+ val manager =
111
+ SignerManager(
112
+ context,
113
+ NoOpLauncher(),
114
+ signerPickerRequestHandler = { pickerRequests++ }
115
+ )
116
+ val first =
117
+ async(start = CoroutineStart.UNDISPATCHED) {
118
+ manager.getConfiguredSigner()
119
+ }
120
+ val second =
121
+ async(start = CoroutineStart.UNDISPATCHED) {
122
+ manager.getConfiguredSigner()
123
+ }
124
+
125
+ assertEquals(1, pickerRequests)
126
+ assertFalse(first.isCompleted)
127
+ assertFalse(second.isCompleted)
128
+
129
+ manager.setActiveType(SignerType.FAKE)
130
+
131
+ assertTrue(first.await() is FakeSigner)
132
+ assertTrue(second.await() is FakeSigner)
133
+ }
134
+
135
+ @Test
136
+ fun dismissed_picker_returns_signer_unavailable() = runBlocking {
137
+ clearSignerPreferences()
138
+ val manager =
139
+ SignerManager(
140
+ context,
141
+ NoOpLauncher(),
142
+ signerPickerRequestHandler = {}
143
+ )
144
+ val request =
145
+ async(start = CoroutineStart.UNDISPATCHED) {
146
+ runCatching { manager.getConfiguredSigner() }.exceptionOrNull()
147
+ }
148
+
149
+ manager.cancelSignerSelection()
150
+
151
+ assertTrue(request.await() is SignerUnavailableException)
152
+ }
153
+
154
+ @Test
155
+ fun close_returns_signer_unavailable_to_pending_request() = runBlocking {
156
+ clearSignerPreferences()
157
+ val manager =
158
+ SignerManager(
159
+ context,
160
+ NoOpLauncher(),
161
+ signerPickerRequestHandler = {}
162
+ )
163
+ val request =
164
+ async(start = CoroutineStart.UNDISPATCHED) {
165
+ runCatching { manager.getConfiguredSigner() }.exceptionOrNull()
166
+ }
167
+
168
+ manager.close()
169
+
170
+ assertTrue(request.await() is SignerUnavailableException)
171
+ }
172
+
27
173
  @Test
28
174
  fun signer_change_clears_cached_user_pubkey() = runBlocking {
29
175
  val context =
@@ -102,6 +248,47 @@ class SignerManagerTest {
102
248
  }
103
249
  }
104
250
 
251
+ @Test
252
+ fun local_account_survives_manager_recreation() = runBlocking {
253
+ val context =
254
+ ApplicationProvider.getApplicationContext<Context>()
255
+ clearLocalAccount(context)
256
+ val first = SignerManager(context, NoOpLauncher())
257
+ val restored = SignerManager(context, NoOpLauncher())
258
+ try {
259
+ first.createLocalAccount()
260
+ val firstPublicKey = first.getCurrentUserPubkey()
261
+
262
+ assertEquals(SignerType.LOCAL, first.getActiveType())
263
+ assertEquals(SignerType.LOCAL, restored.getActiveType())
264
+ assertEquals(firstPublicKey, restored.getCurrentUserPubkey())
265
+ } finally {
266
+ first.close()
267
+ restored.close()
268
+ clearLocalAccount(context)
269
+ }
270
+ }
271
+
272
+ @Test
273
+ fun repeated_local_account_creation_reuses_identity() = runBlocking {
274
+ val context =
275
+ ApplicationProvider.getApplicationContext<Context>()
276
+ clearLocalAccount(context)
277
+ val manager = SignerManager(context, NoOpLauncher())
278
+ try {
279
+ manager.createLocalAccount()
280
+ val firstPublicKey = manager.getCurrentUserPubkey()
281
+
282
+ manager.createLocalAccount()
283
+
284
+ assertEquals(firstPublicKey, manager.getCurrentUserPubkey())
285
+ assertEquals(SignerType.LOCAL, manager.getActiveType())
286
+ } finally {
287
+ manager.close()
288
+ clearLocalAccount(context)
289
+ }
290
+ }
291
+
105
292
  @Test
106
293
  fun switching_from_bunker_disconnects_and_closes_client() {
107
294
  val context =
@@ -149,6 +336,20 @@ class SignerManagerTest {
149
336
  }
150
337
  }
151
338
 
339
+ private fun clearLocalAccount(context: Context) {
340
+ context.getSharedPreferences(
341
+ LocalAccountStore.PREFERENCES_NAME,
342
+ Context.MODE_PRIVATE
343
+ ).edit().clear().commit()
344
+ KeyStore.getInstance("AndroidKeyStore")
345
+ .apply { load(null) }
346
+ .deleteEntry(LocalAccountStore.KEY_ALIAS)
347
+ context.getSharedPreferences("signer", Context.MODE_PRIVATE)
348
+ .edit()
349
+ .clear()
350
+ .commit()
351
+ }
352
+
152
353
  private fun getPendingPairing(manager: SignerManager): Any? {
153
354
  return SignerManager::class.java
154
355
  .getDeclaredField("pendingPairing")
@@ -0,0 +1,194 @@
1
+ package com.pakstr.app.signer.local
2
+
3
+ import android.content.Context
4
+ import android.util.Base64
5
+ import androidx.test.core.app.ApplicationProvider
6
+ import androidx.test.ext.junit.runners.AndroidJUnit4
7
+ import org.junit.After
8
+ import org.junit.Assert.assertEquals
9
+ import org.junit.Assert.assertFalse
10
+ import org.junit.Assert.assertNotEquals
11
+ import org.junit.Assert.assertThrows
12
+ import org.junit.Assert.assertTrue
13
+ import org.junit.Before
14
+ import org.junit.Test
15
+ import org.junit.runner.RunWith
16
+ import java.security.KeyStore
17
+
18
+ @RunWith(AndroidJUnit4::class)
19
+ class LocalAccountStoreTest {
20
+ private lateinit var context: Context
21
+ private lateinit var store: LocalAccountStore
22
+
23
+ @Before
24
+ fun setup() {
25
+ context = ApplicationProvider.getApplicationContext()
26
+ clearState()
27
+ store =
28
+ LocalAccountStore(
29
+ context,
30
+ PREFERENCES_NAME,
31
+ KEY_ALIAS
32
+ )
33
+ }
34
+
35
+ @After
36
+ fun teardown() {
37
+ clearState()
38
+ }
39
+
40
+ @Test
41
+ fun account_survives_store_recreation() {
42
+ val createdPublicKey = store.createAccount()
43
+ val restored =
44
+ LocalAccountStore(
45
+ context,
46
+ PREFERENCES_NAME,
47
+ KEY_ALIAS
48
+ ).loadKey()
49
+
50
+ requireNotNull(restored).use { key ->
51
+ assertEquals(createdPublicKey, key.publicKeyHex)
52
+ }
53
+ }
54
+
55
+ @Test
56
+ fun repeated_creation_reuses_existing_account() {
57
+ val first = store.createAccount()
58
+ val second = store.createAccount()
59
+
60
+ assertEquals(first, second)
61
+ }
62
+
63
+ @Test
64
+ fun persisted_value_is_not_plaintext_private_key() {
65
+ store.createAccount()
66
+ val privateKey =
67
+ requireNotNull(store.loadKey()).use { key ->
68
+ key.withPrivateKey(ByteArray::copyOf)
69
+ }
70
+ val preferences = preferences()
71
+ val ciphertext =
72
+ requireNotNull(
73
+ preferences.getString(
74
+ LocalAccountStore.KEY_CIPHERTEXT,
75
+ null
76
+ )
77
+ )
78
+
79
+ try {
80
+ assertNotEquals(
81
+ Base64.encodeToString(privateKey, Base64.NO_WRAP),
82
+ ciphertext
83
+ )
84
+ assertFalse(
85
+ preferences.all.values.any { value ->
86
+ value == Base64.encodeToString(
87
+ privateKey,
88
+ Base64.NO_WRAP
89
+ )
90
+ }
91
+ )
92
+ } finally {
93
+ privateKey.fill(0)
94
+ }
95
+ }
96
+
97
+ @Test
98
+ fun incomplete_storage_fails_without_being_cleared() {
99
+ preferences().edit()
100
+ .putString(LocalAccountStore.KEY_IV, "AA==")
101
+ .commit()
102
+
103
+ assertThrows(LocalAccountStorageException::class.java) {
104
+ store.createAccount()
105
+ }
106
+ assertTrue(preferences().contains(LocalAccountStore.KEY_IV))
107
+ assertFalse(
108
+ preferences().contains(
109
+ LocalAccountStore.KEY_CIPHERTEXT
110
+ )
111
+ )
112
+ }
113
+
114
+ @Test
115
+ fun tampered_ciphertext_fails_without_replacement() {
116
+ store.createAccount()
117
+ val preferences = preferences()
118
+ val original =
119
+ requireNotNull(
120
+ preferences.getString(
121
+ LocalAccountStore.KEY_CIPHERTEXT,
122
+ null
123
+ )
124
+ )
125
+ val tampered = Base64.decode(original, Base64.NO_WRAP)
126
+ tampered[0] = (tampered[0].toInt() xor 1).toByte()
127
+ val encodedTampered =
128
+ Base64.encodeToString(tampered, Base64.NO_WRAP)
129
+ tampered.fill(0)
130
+ preferences.edit()
131
+ .putString(
132
+ LocalAccountStore.KEY_CIPHERTEXT,
133
+ encodedTampered
134
+ )
135
+ .commit()
136
+
137
+ assertThrows(LocalAccountStorageException::class.java) {
138
+ store.createAccount()
139
+ }
140
+ assertEquals(
141
+ encodedTampered,
142
+ preferences.getString(
143
+ LocalAccountStore.KEY_CIPHERTEXT,
144
+ null
145
+ )
146
+ )
147
+ }
148
+
149
+ @Test
150
+ fun missing_wrapping_key_does_not_generate_replacement_account() {
151
+ store.createAccount()
152
+ val ciphertext =
153
+ preferences().getString(
154
+ LocalAccountStore.KEY_CIPHERTEXT,
155
+ null
156
+ )
157
+ deleteKey()
158
+
159
+ assertThrows(LocalAccountStorageException::class.java) {
160
+ store.createAccount()
161
+ }
162
+ assertEquals(
163
+ ciphertext,
164
+ preferences().getString(
165
+ LocalAccountStore.KEY_CIPHERTEXT,
166
+ null
167
+ )
168
+ )
169
+ }
170
+
171
+ private fun preferences() =
172
+ context.getSharedPreferences(
173
+ PREFERENCES_NAME,
174
+ Context.MODE_PRIVATE
175
+ )
176
+
177
+ private fun clearState() {
178
+ if (::context.isInitialized) {
179
+ preferences().edit().clear().commit()
180
+ deleteKey()
181
+ }
182
+ }
183
+
184
+ private fun deleteKey() {
185
+ KeyStore.getInstance("AndroidKeyStore")
186
+ .apply { load(null) }
187
+ .deleteEntry(KEY_ALIAS)
188
+ }
189
+
190
+ private companion object {
191
+ const val PREFERENCES_NAME = "local_signer_account_test"
192
+ const val KEY_ALIAS = "pakstr_local_signer_wrapping_key_test"
193
+ }
194
+ }
@@ -0,0 +1,131 @@
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.assertion.ViewAssertions.matches
9
+ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
10
+ import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
11
+ import androidx.test.espresso.matcher.ViewMatchers.withId
12
+ import androidx.test.espresso.matcher.ViewMatchers.Visibility.GONE
13
+ import androidx.test.ext.junit.runners.AndroidJUnit4
14
+ import com.pakstr.app.MainActivity
15
+ import com.pakstr.app.R
16
+ import com.pakstr.app.signer.SignerManager
17
+ import com.pakstr.app.signer.SignerType
18
+ import com.pakstr.app.signer.local.LocalAccountStore
19
+ import org.junit.After
20
+ import org.junit.Assert.assertEquals
21
+ import org.junit.Assert.assertNull
22
+ import org.junit.Assert.assertTrue
23
+ import org.junit.Before
24
+ import org.junit.Test
25
+ import org.junit.runner.RunWith
26
+ import java.security.KeyStore
27
+
28
+ @RunWith(AndroidJUnit4::class)
29
+ class SignerPickerBottomSheetTest {
30
+ private lateinit var context: Context
31
+
32
+ @Before
33
+ fun setup() {
34
+ context = ApplicationProvider.getApplicationContext()
35
+ AppPreferences.init(context)
36
+ AppPreferences.saveBoolean("isFirstRun", false)
37
+ clearLocalAccount()
38
+ }
39
+
40
+ @After
41
+ fun teardown() {
42
+ clearLocalAccount()
43
+ context.getSharedPreferences("signer", Context.MODE_PRIVATE)
44
+ .edit()
45
+ .clear()
46
+ .commit()
47
+ AppPreferences.saveBoolean("isFirstRun", true)
48
+ }
49
+
50
+ @Test
51
+ fun cold_start_does_not_show_signer_picker() {
52
+ AppPreferences.saveBoolean("isFirstRun", true)
53
+
54
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
55
+ scenario.onActivity { activity ->
56
+ assertNull(
57
+ activity.supportFragmentManager
58
+ .findFragmentByTag("SignerPicker")
59
+ )
60
+ }
61
+ }
62
+ }
63
+
64
+ @Test
65
+ fun completed_legacy_setup_migrates_signer_configuration() {
66
+ AppPreferences.saveBoolean("isFirstRun", false)
67
+
68
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
69
+ scenario.onActivity { activity ->
70
+ assertTrue(signerManager(activity).isSignerConfigured())
71
+ }
72
+ }
73
+ }
74
+
75
+ @Test
76
+ fun local_option_creates_and_selects_local_signer() {
77
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
78
+ scenario.onActivity { activity ->
79
+ MainActivity::class.java
80
+ .getDeclaredMethod("showSignerPicker")
81
+ .apply { isAccessible = true }
82
+ .invoke(activity)
83
+ }
84
+
85
+ onView(withId(R.id.radio_option_three))
86
+ .check(matches(isDisplayed()))
87
+ .perform(click())
88
+ onView(withId(R.id.bunker_uri_container))
89
+ .check(matches(withEffectiveVisibility(GONE)))
90
+ onView(withId(R.id.button_share_pairing))
91
+ .check(matches(withEffectiveVisibility(GONE)))
92
+ onView(withId(R.id.button_confirm)).perform(click())
93
+
94
+ waitForLocalSelection(scenario)
95
+ }
96
+ }
97
+
98
+ private fun waitForLocalSelection(
99
+ scenario: ActivityScenario<MainActivity>
100
+ ) {
101
+ repeat(50) {
102
+ var activeType: SignerType? = null
103
+ scenario.onActivity { activity ->
104
+ activeType = signerManager(activity).getActiveType()
105
+ }
106
+ if (activeType == SignerType.LOCAL) {
107
+ assertEquals(SignerType.LOCAL, activeType)
108
+ return
109
+ }
110
+ Thread.sleep(100)
111
+ }
112
+ throw AssertionError("LOCAL signer was not selected")
113
+ }
114
+
115
+ private fun signerManager(activity: MainActivity): SignerManager {
116
+ return MainActivity::class.java
117
+ .getDeclaredField("signerManager")
118
+ .apply { isAccessible = true }
119
+ .get(activity) as SignerManager
120
+ }
121
+
122
+ private fun clearLocalAccount() {
123
+ context.getSharedPreferences(
124
+ LocalAccountStore.PREFERENCES_NAME,
125
+ Context.MODE_PRIVATE
126
+ ).edit().clear().commit()
127
+ KeyStore.getInstance("AndroidKeyStore")
128
+ .apply { load(null) }
129
+ .deleteEntry(LocalAccountStore.KEY_ALIAS)
130
+ }
131
+ }