Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/ext/Activities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ fun Activity.isTransfer() = this is Activity.Onchain && this.v1.isTransfer

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

fun Activity.isReplacedSentTransaction(txIdsInBoostTxIds: Set<String>): Boolean =
this is Activity.Onchain &&
!v1.doesExist &&
v1.txType == PaymentType.SENT &&
v1.txId in txIdsInBoostTxIds

fun Activity.paymentState(): PaymentState? = when (this) {
is Activity.Lightning -> this.v1.status
is Activity.Onchain -> null
Expand Down
24 changes: 23 additions & 1 deletion app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import to.bitkit.di.BgDispatcher
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.amountOnClose
import to.bitkit.ext.contact
import to.bitkit.ext.isReplacedSentTransaction
import to.bitkit.ext.matchesPaymentId
import to.bitkit.ext.nowMillis
import to.bitkit.ext.nowTimestamp
Expand Down Expand Up @@ -345,10 +346,13 @@ class ActivityRepo @Inject constructor(
suspend fun contactActivities(publicKey: String): Result<List<Activity>> = withContext(ioDispatcher) {
runCatching {
val normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?: publicKey
val txIdsInBoostTxIds = getTxIdsInBoostTxIds()
getActivities(
filter = ActivityFilter.ALL,
sortDirection = SortDirection.DESC,
).getOrThrow().filter { PubkyPublicKeyFormat.matches(it.contact(), normalizedKey) }
).getOrThrow()
.filterNot { it.isReplacedSentTransaction(txIdsInBoostTxIds) }
.filter { PubkyPublicKeyFormat.matches(it.contact(), normalizedKey) }
}.onFailure {
Logger.error("Failed to load contact activities for '$publicKey'", it, context = TAG)
}
Expand Down Expand Up @@ -382,11 +386,29 @@ class ActivityRepo @Inject constructor(
val updatedAt = nowTimestamp().epochSecond.toULong()
val updatedActivity = activity.withContact(normalizedKey, updatedAt)
updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow()
updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt)
}.onFailure {
Logger.error("Failed to set contact for payment '$forPaymentId'", it, context = TAG)
}
}

private suspend fun updateReplacementContactIfNeeded(
activity: Activity,
normalizedKey: String,
updatedAt: ULong,
) {
if (activity !is Activity.Onchain || activity.v1.doesExist || activity.v1.txType != PaymentType.SENT) return

getActivities(filter = ActivityFilter.ONCHAIN).getOrThrow()
.filterIsInstance<Activity.Onchain>()
.filter { activity.v1.txId in it.v1.boostTxIds }
.filterNot { PubkyPublicKeyFormat.matches(it.v1.contact, normalizedKey) }
.forEach {
val updatedReplacement = Activity.Onchain(it.v1.copy(contact = normalizedKey, updatedAt = updatedAt))
updateActivity(updatedReplacement.rawId(), updatedReplacement).getOrThrow()
}
}

private suspend fun findActivityForPaymentId(forPaymentId: String, syncLdkPayments: Boolean): Activity? {
val activity = getActivityByPaymentId(forPaymentId)
if (activity != null) return activity
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,8 @@ class ActivityService(
val updatedActivity = replacementActivity.copy(
boostTxIds = replacementActivity.boostTxIds + txid,
isBoosted = true,
updatedAt = System.currentTimeMillis().toULong() / 1000u
contact = replacementActivity.contact ?: replacedActivity?.contact,
Comment thread
ben-kaufman marked this conversation as resolved.
updatedAt = System.currentTimeMillis().toULong() / 1000u,
)
updateActivity(replacementActivity.id, Activity.Onchain(updatedActivity))

Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,7 @@ private fun NavGraphBuilder.contacts(
onBackClick = { navController.popBackStack() },
onContactSaved = { navController.popBackStack() },
onPayContact = { paymentRequest, publicKey ->
navController.popBackStack()
appViewModel.openContactPayment(paymentRequest, publicKey)
},
)
Expand Down
15 changes: 2 additions & 13 deletions app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import to.bitkit.di.BgDispatcher
import to.bitkit.ext.isReplacedSentTransaction
import to.bitkit.ext.isTransfer
import to.bitkit.models.PubkyProfile
import to.bitkit.repositories.ActivityRepo
Expand Down Expand Up @@ -146,19 +147,7 @@ class ActivityListViewModel @Inject constructor(

private suspend fun filterOutReplacedSentTransactions(activities: List<Activity>): List<Activity> {
val txIdsInBoostTxIds = activityRepo.getTxIdsInBoostTxIds()

return activities.filter {
if (it is Activity.Onchain) {
val onchain = it.v1
if (!onchain.doesExist &&
onchain.txType == PaymentType.SENT &&
txIdsInBoostTxIds.contains(onchain.txId)
) {
return@filter false
}
}
true
}
return activities.filterNot { it.isReplacedSentTransaction(txIdsInBoostTxIds) }
}

fun updateAvailableTags() {
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,11 @@ class AppViewModel @Inject constructor(
// Skip validation for empty input
if (valueWithoutSpaces.isEmpty()) return

if (PubkyPublicKeyFormat.normalized(valueWithoutSpaces) != null) {
_sendUiState.update { it.copy(isAddressInputValid = true) }
return
}

// Start debounced validation
addressValidationJob = viewModelScope.launch {
delay(ADDRESS_VALIDATION_DEBOUNCE_MS)
Expand Down Expand Up @@ -1139,7 +1144,7 @@ class AppViewModel @Inject constructor(
}

private fun onAddressContinue(data: String) {
launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data)
launchScan(source = ScanSource.ADDRESS_CONTINUE, data = data, routePubkyKeys = true)
}

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

if (route != null) {
clearActiveContactPaymentContext()
if (currentSheet.value is Sheet.Send) hideSheet()
mainScreenEffect(MainScreenEffect.Navigate(route))
return@withContext
}
Expand Down
87 changes: 87 additions & 0 deletions app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ class ActivityRepoTest : BaseUnitTest() {
confirmTimestamp: ULong? = baseOnchainActivity.confirmTimestamp,
channelId: String? = baseOnchainActivity.channelId,
transferTxId: String? = baseOnchainActivity.transferTxId,
contact: String? = baseOnchainActivity.contact,
createdAt: ULong? = baseOnchainActivity.createdAt,
updatedAt: ULong? = baseOnchainActivity.updatedAt,
): Activity.Onchain {
Expand All @@ -107,6 +108,7 @@ class ActivityRepoTest : BaseUnitTest() {
confirmTimestamp = confirmTimestamp,
channelId = channelId,
transferTxId = transferTxId,
contact = contact,
createdAt = createdAt,
updatedAt = updatedAt
)
Expand Down Expand Up @@ -267,6 +269,91 @@ class ActivityRepoTest : BaseUnitTest() {
assertNull(result.getOrThrow())
}

@Test
fun `contactActivities filters replaced sent transaction`() = test {
val contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
val replacedTxId = "replaced_tx_id"
val replacedActivity = createOnchainActivity(
id = "replaced_activity_id",
txId = replacedTxId,
doesExist = false,
contact = contactPublicKey,
)
val replacementActivity = createOnchainActivity(
id = "replacement_activity_id",
txId = "replacement_tx_id",
boostTxIds = listOf(replacedTxId),
contact = contactPublicKey,
)
whenever(coreService.activity.getTxIdsInBoostTxIds()).thenReturn(setOf(replacedTxId))
whenever(
coreService.activity.get(
filter = ActivityFilter.ALL,
txType = null,
tags = null,
search = null,
minDate = null,
maxDate = null,
limit = null,
sortDirection = SortDirection.DESC,
)
).thenReturn(listOf(replacedActivity, replacementActivity))

val result = sut.contactActivities(contactPublicKey)

assertEquals(listOf(replacementActivity), result.getOrThrow())
}

@Test
fun `setContact propagates contact to replacement transaction`() = test {
val contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
val replacedTxId = "replaced_tx_id"
val replacedActivity = createOnchainActivity(
id = "replaced_activity_id",
txId = replacedTxId,
doesExist = false,
)
val replacementActivity = createOnchainActivity(
id = "replacement_activity_id",
txId = "replacement_tx_id",
boostTxIds = listOf(replacedTxId),
)
whenever(coreService.activity.getActivity(replacedTxId)).thenReturn(null)
whenever(coreService.activity.getOnchainActivityByTxId(replacedTxId)).thenReturn(replacedActivity.v1)
whenever(
coreService.activity.get(
filter = ActivityFilter.ONCHAIN,
txType = null,
tags = null,
search = null,
minDate = null,
maxDate = null,
limit = null,
sortDirection = null,
)
).thenReturn(listOf(replacedActivity, replacementActivity))

val result = sut.setContact(
contactPublicKey = contactPublicKey,
forPaymentId = replacedTxId,
syncLdkPayments = false,
)

assertTrue(result.isSuccess)
verify(coreService.activity).update(
eq(replacedActivity.v1.id),
argThat {
this is Activity.Onchain && v1.contact == contactPublicKey
},
)
verify(coreService.activity).update(
eq(replacementActivity.v1.id),
argThat {
this is Activity.Onchain && v1.contact == contactPublicKey
},
)
}

@Test
fun `updateActivity updates successfully when not deleted`() = test {
val activityId = "activity123"
Expand Down
38 changes: 38 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package to.bitkit.viewmodels

import android.content.Context
import app.cash.turbine.test
import com.synonym.bitkitcore.LightningInvoice
import com.synonym.bitkitcore.NetworkType
import com.synonym.bitkitcore.Scanner
Expand All @@ -17,6 +18,7 @@ import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import to.bitkit.data.AppCacheData
Expand Down Expand Up @@ -50,6 +52,7 @@ import to.bitkit.services.AppUpdaterService
import to.bitkit.services.CoreService
import to.bitkit.services.MigrationService
import to.bitkit.test.BaseUnitTest
import to.bitkit.ui.Routes
import to.bitkit.ui.components.Sheet
import to.bitkit.ui.shared.toast.ToastQueueManager
import to.bitkit.ui.sheets.SendRoute
Expand Down Expand Up @@ -93,6 +96,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
private val settingsData = MutableStateFlow(SettingsData())
private val walletState = MutableStateFlow(WalletState())
private val nodeEvents = MutableSharedFlow<Event>()
private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"

private val timedSheetManager = mock<TimedSheetManager>()

Expand Down Expand Up @@ -123,6 +127,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
whenever { widgetsRepo.refreshEnabledWidgets() }.thenReturn(Unit)
whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit)
whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false))
whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null))
whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList()))
whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull()))
.thenReturn(Result.failure(Exception("not mocked")))
whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) }
Expand Down Expand Up @@ -218,6 +224,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
assertFalse(sut.sendUiState.value.canSwitchWallet)
}

@Test
fun `manual address continue routes pubky to add contact`() = test {
sut.mainScreenEffect.test {
sut.setSendEvent(SendEvent.AddressContinue(testPublicKey))

assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem())
}
}

@Test
fun `manual address input accepts pubky without decode error`() = test {
sut.setSendEvent(SendEvent.AddressChange(testPublicKey))
advanceUntilIdle()

assertEquals(testPublicKey, sut.sendUiState.value.addressInput)
assertTrue(sut.sendUiState.value.isAddressInputValid)
verify(coreService, never()).decode(any())
}

@Test
fun `pubky routing dismisses send sheet before navigation`() = test {
sut.showSheet(Sheet.Send())
advanceUntilIdle()

sut.mainScreenEffect.test {
sut.setSendEvent(SendEvent.AddressContinue(testPublicKey))

assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem())
assertNull(sut.currentSheet.value)
}
}

@Test
fun `canSwitchWallet is false when amount exceeds lightning balance`() = test {
balanceState.value = BalanceState(
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/931.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improved public contact payment flows for manual Pubky entry, add-contact payments, and RBF activity display.
Loading