Skip to content

Commit b459513

Browse files
committed
fix: sync private paykit preference state
1 parent 1a5e00a commit b459513

7 files changed

Lines changed: 184 additions & 62 deletions

File tree

app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ class PrivatePaykitRepo @Inject constructor(
112112
): Result<Unit> = withContext(serializedDispatcher) {
113113
runCatching {
114114
val keys = rememberSavedContacts(publicKeys, replacing = true)
115-
if (!canPublishPrivateEndpoints()) return@runCatching
115+
if (!canPublishPrivateEndpoints()) {
116+
if (requireImmediatePublication && keys.isNotEmpty()) throw PrivatePaykitError.PrivateUnavailable
117+
return@runCatching
118+
}
116119
if (isProfileRecoveryPending() && keys.isNotEmpty()) {
117120
recoverSavedContactsAfterProfileRecreation(
118121
publicKeys = keys,
@@ -162,13 +165,19 @@ class PrivatePaykitRepo @Inject constructor(
162165
}
163166
}
164167

165-
suspend fun enableSharingAndPrepareSavedContacts(publicKeys: Collection<String>): Result<Unit> =
168+
suspend fun enableSharingAndPrepareSavedContacts(
169+
publicKeys: Collection<String>,
170+
requireImmediatePublication: Boolean = false,
171+
): Result<Unit> =
166172
withContext(serializedDispatcher) {
167173
runCatching {
168174
val wasCleanupPending = isContactSharingCleanupPending()
169-
if (wasCleanupPending && !canPublishPrivateEndpoints()) return@runCatching
175+
if (wasCleanupPending && !canPublishPrivateEndpoints()) {
176+
if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable
177+
return@runCatching
178+
}
170179
updateContactSharingCleanupPending(false)
171-
prepareSavedContacts(publicKeys).onFailure {
180+
prepareSavedContacts(publicKeys, requireImmediatePublication).onFailure {
172181
if (wasCleanupPending) {
173182
runCatching { updateContactSharingCleanupPending(true) }
174183
.onFailure(it::addSuppressed)
@@ -272,6 +281,7 @@ class PrivatePaykitRepo @Inject constructor(
272281
removalError,
273282
context = TAG,
274283
)
284+
throw removalError
275285
} else {
276286
clearUnsavedContactState(savedPublicKeys).getOrThrow()
277287
updateContactSharingCleanupPending(false)
@@ -691,9 +701,7 @@ class PrivatePaykitRepo @Inject constructor(
691701
generation = generation,
692702
scheduleRetries = scheduleRetries,
693703
) ?: run {
694-
val shouldFailImmediatePublish = requireImmediatePublication &&
695-
shouldRequirePrivateEndpointRemoval(normalizedKey)
696-
if (firstError == null && shouldFailImmediatePublish) {
704+
if (firstError == null && requireImmediatePublication) {
697705
firstError = PrivatePaykitError.PrivateUnavailable
698706
}
699707
return@forEach
@@ -710,9 +718,7 @@ class PrivatePaykitRepo @Inject constructor(
710718
scheduleRetries = scheduleRetries,
711719
generation = generation,
712720
) ?: run {
713-
val shouldFailImmediatePublish = requireImmediatePublication &&
714-
shouldRequirePrivateEndpointRemoval(normalizedKey)
715-
if (firstError == null && shouldFailImmediatePublish) {
721+
if (firstError == null && requireImmediatePublication) {
716722
firstError = PrivatePaykitError.PrivateUnavailable
717723
}
718724
return@forEach

app/src/main/java/to/bitkit/ui/screens/profile/PayContactsViewModel.kt

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,19 +153,27 @@ class PayContactsViewModel @Inject constructor(
153153
error.addSuppressed(rollbackError)
154154
}
155155
}
156+
privateCleanupError?.let { error ->
157+
runCatching {
158+
settingsStore.update { settings ->
159+
settings.copy(sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints)
160+
}
161+
}.onFailure { rollbackError ->
162+
error.addSuppressed(rollbackError)
163+
}
164+
if (previous.sharesPrivatePaykitEndpoints) {
165+
privatePaykitRepo.setContactSharingCleanupPending(false)
166+
.onFailure(error::addSuppressed)
167+
privatePaykitRepo.prepareSavedContacts(contacts)
168+
.onFailure(error::addSuppressed)
169+
}
170+
}
156171

157172
val cleanupError = publicCleanupError ?: privateCleanupError
158173
publicCleanupError?.let { publicError ->
159174
privateCleanupError?.let { privateError -> publicError.addSuppressed(privateError) }
160175
}
161176
cleanupError?.let {
162-
if (privateCleanupError != null) {
163-
privatePaykitRepo.setContactSharingCleanupPending(true)
164-
.onFailure { markerError ->
165-
it.addSuppressed(markerError)
166-
return Result.failure(it)
167-
}
168-
}
169177
return Result.failure(it)
170178
}
171179

app/src/main/java/to/bitkit/ui/settings/paymentPreference/PaymentPreferenceViewModel.kt

Lines changed: 78 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.first
1414
import kotlinx.coroutines.flow.update
1515
import kotlinx.coroutines.launch
1616
import to.bitkit.R
17+
import to.bitkit.data.SettingsData
1718
import to.bitkit.data.SettingsStore
1819
import to.bitkit.models.Toast
1920
import to.bitkit.repositories.PrivatePaykitRepo
@@ -33,26 +34,27 @@ class PaymentPreferenceViewModel @Inject constructor(
3334
) : ViewModel() {
3435
private val _uiState = MutableStateFlow(PaymentPreferenceUiState())
3536
val uiState: StateFlow<PaymentPreferenceUiState> = _uiState.asStateFlow()
37+
private val privateContactsPendingValue = MutableStateFlow<Boolean?>(null)
3638

3739
init {
3840
viewModelScope.launch {
39-
combine(settingsStore.data, pubkyRepo.isAuthenticated) { settings, isAuthenticated ->
40-
settings to isAuthenticated
41-
}.collect { (settings, isAuthenticated) ->
41+
combine(
42+
settingsStore.data,
43+
pubkyRepo.isAuthenticated,
44+
privateContactsPendingValue,
45+
) { settings, isAuthenticated, pendingPrivateContactsEnabled ->
4246
val canUsePrivateContacts = isAuthenticated && pubkyRepo.hasSecretKey()
4347
if (!canUsePrivateContacts && settings.sharesPrivatePaykitEndpoints) {
4448
settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
4549
}
46-
_uiState.update {
47-
it.copy(
48-
lightningEnabled = settings.publicPaykitLightningEnabled,
49-
onchainEnabled = settings.publicPaykitOnchainEnabled,
50-
privateContactsEnabled = settings.sharesPrivatePaykitEndpoints && canUsePrivateContacts,
51-
publicContactsEnabled = settings.sharesPublicPaykitEndpoints,
52-
hasPubkyProfile = isAuthenticated,
53-
canUsePrivateContacts = canUsePrivateContacts,
54-
)
55-
}
50+
PaymentPreferenceStateSource(
51+
settings = settings,
52+
isAuthenticated = isAuthenticated,
53+
canUsePrivateContacts = canUsePrivateContacts,
54+
pendingPrivateContactsEnabled = pendingPrivateContactsEnabled,
55+
)
56+
}.collect { stateSource ->
57+
_uiState.update { it.from(stateSource) }
5658
}
5759
}
5860
}
@@ -76,32 +78,36 @@ class PaymentPreferenceViewModel @Inject constructor(
7678
return
7779
}
7880
viewModelScope.launch {
79-
_uiState.update { it.copy(isUpdatingPrivateContacts = true) }
8081
val previous = settingsStore.data.first()
81-
settingsStore.update {
82-
it.copy(
83-
hasConfirmedPublicPaykitEndpoints = true,
84-
sharesPrivatePaykitEndpoints = isEnabled,
85-
)
86-
}
87-
88-
val result = if (isEnabled) {
89-
privatePaykitRepo.enableSharingAndPrepareSavedContacts(contactPublicKeys())
90-
} else {
91-
privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contactPublicKeys())
92-
}
93-
94-
result.exceptionOrNull()?.let {
95-
if (!isEnabled) {
96-
privatePaykitRepo.setContactSharingCleanupPending(true)
82+
privateContactsPendingValue.update { _uiState.value.privateContactsEnabled }
83+
_uiState.update { it.copy(isUpdatingPrivateContacts = true) }
84+
val result = runCatching {
85+
settingsStore.update {
86+
it.copy(
87+
hasConfirmedPublicPaykitEndpoints = true,
88+
sharesPrivatePaykitEndpoints = isEnabled,
89+
)
9790
}
91+
}.mapCatching {
9892
if (isEnabled) {
99-
settingsStore.update { settings ->
100-
settings.copy(sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints)
101-
}
93+
privatePaykitRepo.enableSharingAndPrepareSavedContacts(
94+
publicKeys = contactPublicKeys(),
95+
requireImmediatePublication = true,
96+
).getOrThrow()
97+
} else {
98+
privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contactPublicKeys()).getOrThrow()
10299
}
100+
}
101+
102+
result.exceptionOrNull()?.let {
103+
rollbackPrivateContactsPreference(
104+
requestedEnabled = isEnabled,
105+
previous = previous,
106+
error = it,
107+
)
103108
showSyncError(it)
104109
}
110+
privateContactsPendingValue.update { null }
105111
_uiState.update { it.copy(isUpdatingPrivateContacts = false) }
106112
}
107113
}
@@ -196,6 +202,28 @@ class PaymentPreferenceViewModel @Inject constructor(
196202
private fun contactPublicKeys(): List<String> =
197203
pubkyRepo.contacts.value.map { it.publicKey }
198204

205+
private suspend fun rollbackPrivateContactsPreference(
206+
requestedEnabled: Boolean,
207+
previous: SettingsData,
208+
error: Throwable,
209+
) {
210+
val contacts = contactPublicKeys()
211+
runCatching {
212+
settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = previous.sharesPrivatePaykitEndpoints) }
213+
}.onFailure(error::addSuppressed)
214+
215+
if (requestedEnabled && !previous.sharesPrivatePaykitEndpoints) {
216+
privatePaykitRepo.disableSharingAndPruneUnsavedContactState(contacts)
217+
.onFailure(error::addSuppressed)
218+
}
219+
if (!requestedEnabled && previous.sharesPrivatePaykitEndpoints) {
220+
privatePaykitRepo.setContactSharingCleanupPending(false)
221+
.onFailure(error::addSuppressed)
222+
privatePaykitRepo.prepareSavedContacts(contacts)
223+
.onFailure(error::addSuppressed)
224+
}
225+
}
226+
199227
private suspend fun showSyncError(error: Throwable) {
200228
ToastEventBus.send(
201229
type = Toast.ToastType.ERROR,
@@ -227,3 +255,20 @@ data class PaymentPreferenceUiState(
227255
val isUpdatingPrivateContacts: Boolean = false,
228256
val isUpdatingPublicContacts: Boolean = false,
229257
)
258+
259+
private data class PaymentPreferenceStateSource(
260+
val settings: SettingsData,
261+
val isAuthenticated: Boolean,
262+
val canUsePrivateContacts: Boolean,
263+
val pendingPrivateContactsEnabled: Boolean?,
264+
)
265+
266+
private fun PaymentPreferenceUiState.from(source: PaymentPreferenceStateSource) = copy(
267+
lightningEnabled = source.settings.publicPaykitLightningEnabled,
268+
onchainEnabled = source.settings.publicPaykitOnchainEnabled,
269+
privateContactsEnabled = source.pendingPrivateContactsEnabled
270+
?: (source.settings.sharesPrivatePaykitEndpoints && source.canUsePrivateContacts),
271+
publicContactsEnabled = source.settings.sharesPublicPaykitEndpoints,
272+
hasPubkyProfile = source.isAuthenticated,
273+
canUsePrivateContacts = source.canUsePrivateContacts,
274+
)

app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
430430
}
431431

432432
@Test
433-
fun `disableSharingAndPruneUnsavedContactState defers cleanup when endpoint removal fails`() = test {
433+
fun `disableSharingAndPruneUnsavedContactState fails and defers cleanup when endpoint removal fails`() = test {
434434
restoreContactBackup()
435435
rememberSavedContact()
436436
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX)
@@ -440,7 +440,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
440440

441441
val result = sut.disableSharingAndPruneUnsavedContactState(listOf(CONTACT_KEY))
442442

443-
assertTrue(result.isSuccess)
443+
assertTrue(result.isFailure)
444444
assertTrue(cacheData.value.cleanupPending)
445445
assertNotNull(sut.backupSnapshot().getOrThrow()?.get(CONTACT_KEY))
446446
}
@@ -572,6 +572,22 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
572572
verify(pubkyService, never()).setPrivatePayments(any(), any())
573573
}
574574

575+
@Test
576+
fun `prepareSavedContacts fails immediate publication when private key is unavailable`() = test {
577+
startForegroundWithSharingEnabled()
578+
whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY)
579+
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
580+
581+
val result = sut.prepareSavedContacts(
582+
publicKeys = listOf(CONTACT_KEY),
583+
requireImmediatePublication = true,
584+
)
585+
586+
assertEquals(PrivatePaykitError.PrivateUnavailable, result.exceptionOrNull())
587+
verify(pubkyService, never()).restoreEncryptedLink(any(), any())
588+
verify(pubkyService, never()).setPrivatePayments(any(), any())
589+
}
590+
575591
@Test
576592
fun `enableSharingAndPrepareSavedContacts restores pending cleanup marker when prepare fails`() = test {
577593
startForegroundWithSharingEnabled()
@@ -691,7 +707,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
691707
}
692708

693709
@Test
694-
fun `prepareSavedContacts defers fresh link when immediate publication is requested`() = test {
710+
fun `prepareSavedContacts fails fresh link when immediate publication is requested`() = test {
695711
startForegroundWithSharingEnabled()
696712
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(SECRET_KEY_HEX)
697713
whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY)
@@ -702,7 +718,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
702718
requireImmediatePublication = true,
703719
)
704720

705-
assertTrue(result.isSuccess)
721+
assertEquals(PrivatePaykitError.PrivateUnavailable, result.exceptionOrNull())
706722
verify(pubkyService, never()).setPrivatePayments(any(), any())
707723
}
708724

app/src/test/java/to/bitkit/ui/screens/profile/PayContactsViewModelTest.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,34 @@ class PayContactsViewModelTest : BaseUnitTest() {
198198
assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints)
199199
assertFalse(sut.uiState.value.isLoading)
200200
assertFalse(sut.uiState.value.isPaymentSharingEnabled)
201-
verify(privatePaykitRepo).setContactSharingCleanupPending(true)
201+
verify(privatePaykitRepo, never()).setContactSharingCleanupPending(true)
202+
}
203+
204+
@Test
205+
fun `continueToProfile restores private sharing when private cleanup fails`() = test {
206+
settingsFlow.value = SettingsData(
207+
hasConfirmedPublicPaykitEndpoints = true,
208+
sharesPrivatePaykitEndpoints = true,
209+
)
210+
whenever { privatePaykitRepo.disableSharingAndPruneUnsavedContactState(any<Collection<String>>()) }
211+
.thenReturn(Result.failure(PayContactsTestAppError("cleanup failed")))
212+
val sut = createSut()
213+
advanceUntilIdle()
214+
215+
sut.effects.test {
216+
sut.setPaymentSharingEnabled(false)
217+
sut.continueToProfile()
218+
advanceUntilIdle()
219+
220+
expectNoEvents()
221+
}
222+
223+
assertTrue(settingsFlow.value.hasConfirmedPublicPaykitEndpoints)
224+
assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints)
225+
assertFalse(sut.uiState.value.isLoading)
226+
assertTrue(sut.uiState.value.isPaymentSharingEnabled)
227+
verify(privatePaykitRepo).setContactSharingCleanupPending(false)
228+
verify(privatePaykitRepo).prepareSavedContacts(listOf(CONTACT_KEY), false)
202229
}
203230

204231
@Test

0 commit comments

Comments
 (0)