Skip to content

Commit e39c878

Browse files
authored
Merge pull request #933 from synonymdev/codex/fix-public-payments-polish
2 parents 6b1d680 + 29fdd32 commit e39c878

9 files changed

Lines changed: 167 additions & 16 deletions

File tree

app/src/main/java/to/bitkit/ext/Activities.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ fun Activity.isTransfer() = this is Activity.Onchain && this.v1.isTransfer
6666

6767
fun Activity.doesExist() = this is Activity.Onchain && this.v1.doesExist
6868

69+
fun Activity.isReplacedSentTransaction(txIdsInBoostTxIds: Set<String>): Boolean =
70+
this is Activity.Onchain &&
71+
!v1.doesExist &&
72+
v1.txType == PaymentType.SENT &&
73+
v1.txId in txIdsInBoostTxIds
74+
6975
fun Activity.paymentState(): PaymentState? = when (this) {
7076
is Activity.Lightning -> this.v1.status
7177
is Activity.Onchain -> null

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import to.bitkit.di.BgDispatcher
3636
import to.bitkit.di.IoDispatcher
3737
import to.bitkit.ext.amountOnClose
3838
import to.bitkit.ext.contact
39+
import to.bitkit.ext.isReplacedSentTransaction
3940
import to.bitkit.ext.matchesPaymentId
4041
import to.bitkit.ext.nowMillis
4142
import to.bitkit.ext.nowTimestamp
@@ -345,10 +346,13 @@ class ActivityRepo @Inject constructor(
345346
suspend fun contactActivities(publicKey: String): Result<List<Activity>> = withContext(ioDispatcher) {
346347
runCatching {
347348
val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey
349+
val txIdsInBoostTxIds = getTxIdsInBoostTxIds()
348350
getActivities(
349351
filter = ActivityFilter.ALL,
350352
sortDirection = SortDirection.DESC,
351-
).getOrThrow().filter { PubkyPublicKeyFormat.matches(it.contact(), normalizedKey) }
353+
).getOrThrow()
354+
.filterNot { it.isReplacedSentTransaction(txIdsInBoostTxIds) }
355+
.filter { PubkyPublicKeyFormat.matches(it.contact(), normalizedKey) }
352356
}.onFailure {
353357
Logger.error("Failed to load contact activities for '$publicKey'", it, context = TAG)
354358
}
@@ -382,11 +386,29 @@ class ActivityRepo @Inject constructor(
382386
val updatedAt = nowTimestamp().epochSecond.toULong()
383387
val updatedActivity = activity.withContact(normalizedKey, updatedAt)
384388
updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow()
389+
updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt)
385390
}.onFailure {
386391
Logger.error("Failed to set contact for payment '$forPaymentId'", it, context = TAG)
387392
}
388393
}
389394

395+
private suspend fun updateReplacementContactIfNeeded(
396+
activity: Activity,
397+
normalizedKey: String,
398+
updatedAt: ULong,
399+
) {
400+
if (activity !is Activity.Onchain || activity.v1.doesExist || activity.v1.txType != PaymentType.SENT) return
401+
402+
getActivities(filter = ActivityFilter.ONCHAIN).getOrThrow()
403+
.filterIsInstance<Activity.Onchain>()
404+
.filter { activity.v1.txId in it.v1.boostTxIds }
405+
.filterNot { PubkyPublicKeyFormat.matches(it.v1.contact, normalizedKey) }
406+
.forEach {
407+
val updatedReplacement = Activity.Onchain(it.v1.copy(contact = normalizedKey, updatedAt = updatedAt))
408+
updateActivity(updatedReplacement.rawId(), updatedReplacement).getOrThrow()
409+
}
410+
}
411+
390412
private suspend fun findActivityForPaymentId(forPaymentId: String, syncLdkPayments: Boolean): Activity? {
391413
val activity = getActivityByPaymentId(forPaymentId)
392414
if (activity != null) return activity

app/src/main/java/to/bitkit/services/CoreService.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1021,7 +1021,8 @@ class ActivityService(
10211021
val updatedActivity = replacementActivity.copy(
10221022
boostTxIds = replacementActivity.boostTxIds + txid,
10231023
isBoosted = true,
1024-
updatedAt = System.currentTimeMillis().toULong() / 1000u
1024+
contact = replacementActivity.contact ?: replacedActivity?.contact,
1025+
updatedAt = System.currentTimeMillis().toULong() / 1000u,
10251026
)
10261027
updateActivity(replacementActivity.id, Activity.Onchain(updatedActivity))
10271028

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,7 @@ private fun NavGraphBuilder.contacts(
10051005
onBackClick = { navController.popBackStack() },
10061006
onContactSaved = { navController.popBackStack() },
10071007
onPayContact = { paymentRequest, publicKey ->
1008+
navController.popBackStack()
10081009
appViewModel.openContactPayment(paymentRequest, publicKey)
10091010
},
10101011
)

app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.stateIn
2626
import kotlinx.coroutines.flow.update
2727
import kotlinx.coroutines.launch
2828
import to.bitkit.di.BgDispatcher
29+
import to.bitkit.ext.isReplacedSentTransaction
2930
import to.bitkit.ext.isTransfer
3031
import to.bitkit.models.PubkyProfile
3132
import to.bitkit.repositories.ActivityRepo
@@ -146,19 +147,7 @@ class ActivityListViewModel @Inject constructor(
146147

147148
private suspend fun filterOutReplacedSentTransactions(activities: List<Activity>): List<Activity> {
148149
val txIdsInBoostTxIds = activityRepo.getTxIdsInBoostTxIds()
149-
150-
return activities.filter {
151-
if (it is Activity.Onchain) {
152-
val onchain = it.v1
153-
if (!onchain.doesExist &&
154-
onchain.txType == PaymentType.SENT &&
155-
txIdsInBoostTxIds.contains(onchain.txId)
156-
) {
157-
return@filter false
158-
}
159-
}
160-
true
161-
}
150+
return activities.filterNot { it.isReplacedSentTransaction(txIdsInBoostTxIds) }
162151
}
163152

164153
fun updateAvailableTags() {

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,11 @@ class AppViewModel @Inject constructor(
939939
// Skip validation for empty input
940940
if (valueWithoutSpaces.isEmpty()) return
941941

942+
if (PubkyPublicKeyFormat.normalized(valueWithoutSpaces) != null) {
943+
_sendUiState.update { it.copy(isAddressInputValid = true) }
944+
return
945+
}
946+
942947
// Start debounced validation
943948
addressValidationJob = viewModelScope.launch {
944949
delay(ADDRESS_VALIDATION_DEBOUNCE_MS)
@@ -1139,7 +1144,7 @@ class AppViewModel @Inject constructor(
11391144
}
11401145

11411146
private fun onAddressContinue(data: String) {
1142-
launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data)
1147+
launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data, routePubkyKeys = true)
11431148
}
11441149

11451150
private suspend fun onAmountChange(amount: ULong) {
@@ -1420,6 +1425,7 @@ class AppViewModel @Inject constructor(
14201425

14211426
if (route != null) {
14221427
clearActiveContactPaymentContext()
1428+
if (currentSheet.value is Sheet.Send) hideSheet()
14231429
mainScreenEffect(MainScreenEffect.Navigate(route))
14241430
return@withContext
14251431
}

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ class ActivityRepoTest : BaseUnitTest() {
8787
confirmTimestamp: ULong? = baseOnchainActivity.confirmTimestamp,
8888
channelId: String? = baseOnchainActivity.channelId,
8989
transferTxId: String? = baseOnchainActivity.transferTxId,
90+
contact: String? = baseOnchainActivity.contact,
9091
createdAt: ULong? = baseOnchainActivity.createdAt,
9192
updatedAt: ULong? = baseOnchainActivity.updatedAt,
9293
): Activity.Onchain {
@@ -107,6 +108,7 @@ class ActivityRepoTest : BaseUnitTest() {
107108
confirmTimestamp = confirmTimestamp,
108109
channelId = channelId,
109110
transferTxId = transferTxId,
111+
contact = contact,
110112
createdAt = createdAt,
111113
updatedAt = updatedAt
112114
)
@@ -267,6 +269,91 @@ class ActivityRepoTest : BaseUnitTest() {
267269
assertNull(result.getOrThrow())
268270
}
269271

272+
@Test
273+
fun `contactActivities filters replaced sent transaction`() = test {
274+
val contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
275+
val replacedTxId = "replaced_tx_id"
276+
val replacedActivity = createOnchainActivity(
277+
id = "replaced_activity_id",
278+
txId = replacedTxId,
279+
doesExist = false,
280+
contact = contactPublicKey,
281+
)
282+
val replacementActivity = createOnchainActivity(
283+
id = "replacement_activity_id",
284+
txId = "replacement_tx_id",
285+
boostTxIds = listOf(replacedTxId),
286+
contact = contactPublicKey,
287+
)
288+
whenever(coreService.activity.getTxIdsInBoostTxIds()).thenReturn(setOf(replacedTxId))
289+
whenever(
290+
coreService.activity.get(
291+
filter = ActivityFilter.ALL,
292+
txType = null,
293+
tags = null,
294+
search = null,
295+
minDate = null,
296+
maxDate = null,
297+
limit = null,
298+
sortDirection = SortDirection.DESC,
299+
)
300+
).thenReturn(listOf(replacedActivity, replacementActivity))
301+
302+
val result = sut.contactActivities(contactPublicKey)
303+
304+
assertEquals(listOf(replacementActivity), result.getOrThrow())
305+
}
306+
307+
@Test
308+
fun `setContact propagates contact to replacement transaction`() = test {
309+
val contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
310+
val replacedTxId = "replaced_tx_id"
311+
val replacedActivity = createOnchainActivity(
312+
id = "replaced_activity_id",
313+
txId = replacedTxId,
314+
doesExist = false,
315+
)
316+
val replacementActivity = createOnchainActivity(
317+
id = "replacement_activity_id",
318+
txId = "replacement_tx_id",
319+
boostTxIds = listOf(replacedTxId),
320+
)
321+
whenever(coreService.activity.getActivity(replacedTxId)).thenReturn(null)
322+
whenever(coreService.activity.getOnchainActivityByTxId(replacedTxId)).thenReturn(replacedActivity.v1)
323+
whenever(
324+
coreService.activity.get(
325+
filter = ActivityFilter.ONCHAIN,
326+
txType = null,
327+
tags = null,
328+
search = null,
329+
minDate = null,
330+
maxDate = null,
331+
limit = null,
332+
sortDirection = null,
333+
)
334+
).thenReturn(listOf(replacedActivity, replacementActivity))
335+
336+
val result = sut.setContact(
337+
contactPublicKey = contactPublicKey,
338+
forPaymentId = replacedTxId,
339+
syncLdkPayments = false,
340+
)
341+
342+
assertTrue(result.isSuccess)
343+
verify(coreService.activity).update(
344+
eq(replacedActivity.v1.id),
345+
argThat {
346+
this is Activity.Onchain && v1.contact == contactPublicKey
347+
},
348+
)
349+
verify(coreService.activity).update(
350+
eq(replacementActivity.v1.id),
351+
argThat {
352+
this is Activity.Onchain && v1.contact == contactPublicKey
353+
},
354+
)
355+
}
356+
270357
@Test
271358
fun `updateActivity updates successfully when not deleted`() = test {
272359
val activityId = "activity123"

app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package to.bitkit.viewmodels
22

33
import android.content.Context
4+
import app.cash.turbine.test
45
import com.synonym.bitkitcore.LightningInvoice
56
import com.synonym.bitkitcore.NetworkType
67
import com.synonym.bitkitcore.Scanner
@@ -17,6 +18,7 @@ import org.mockito.kotlin.any
1718
import org.mockito.kotlin.anyOrNull
1819
import org.mockito.kotlin.clearInvocations
1920
import org.mockito.kotlin.mock
21+
import org.mockito.kotlin.never
2022
import org.mockito.kotlin.verify
2123
import org.mockito.kotlin.whenever
2224
import to.bitkit.data.AppCacheData
@@ -50,6 +52,7 @@ import to.bitkit.services.AppUpdaterService
5052
import to.bitkit.services.CoreService
5153
import to.bitkit.services.MigrationService
5254
import to.bitkit.test.BaseUnitTest
55+
import to.bitkit.ui.Routes
5356
import to.bitkit.ui.components.Sheet
5457
import to.bitkit.ui.shared.toast.ToastQueueManager
5558
import to.bitkit.ui.sheets.SendRoute
@@ -93,6 +96,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
9396
private val settingsData = MutableStateFlow(SettingsData())
9497
private val walletState = MutableStateFlow(WalletState())
9598
private val nodeEvents = MutableSharedFlow<Event>()
99+
private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
96100

97101
private val timedSheetManager = mock<TimedSheetManager>()
98102

@@ -123,6 +127,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
123127
whenever { widgetsRepo.refreshEnabledWidgets() }.thenReturn(Unit)
124128
whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit)
125129
whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false))
130+
whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null))
131+
whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList()))
126132
whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull()))
127133
.thenReturn(Result.failure(Exception("not mocked")))
128134
whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) }
@@ -218,6 +224,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
218224
assertFalse(sut.sendUiState.value.canSwitchWallet)
219225
}
220226

227+
@Test
228+
fun `manual address continue routes pubky to add contact`() = test {
229+
sut.mainScreenEffect.test {
230+
sut.setSendEvent(SendEvent.AddressContinue(testPublicKey))
231+
232+
assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem())
233+
}
234+
}
235+
236+
@Test
237+
fun `manual address input accepts pubky without decode error`() = test {
238+
sut.setSendEvent(SendEvent.AddressChange(testPublicKey))
239+
advanceUntilIdle()
240+
241+
assertEquals(testPublicKey, sut.sendUiState.value.addressInput)
242+
assertTrue(sut.sendUiState.value.isAddressInputValid)
243+
verify(coreService, never()).decode(any())
244+
}
245+
246+
@Test
247+
fun `pubky routing dismisses send sheet before navigation`() = test {
248+
sut.showSheet(Sheet.Send())
249+
advanceUntilIdle()
250+
251+
sut.mainScreenEffect.test {
252+
sut.setSendEvent(SendEvent.AddressContinue(testPublicKey))
253+
254+
assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem())
255+
assertNull(sut.currentSheet.value)
256+
}
257+
}
258+
221259
@Test
222260
fun `canSwitchWallet is false when amount exceeds lightning balance`() = test {
223261
balanceState.value = BalanceState(

changelog.d/next/931.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improved public contact payment flows for manual Pubky entry, add-contact payments, and RBF activity display.

0 commit comments

Comments
 (0)