Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand Down Expand Up @@ -51,6 +54,8 @@ class AddContactViewModel @Inject constructor(
private val _effects = MutableSharedFlow<AddContactEffect>(extraBufferCapacity = 1)
val effects = _effects.asSharedFlow()

private var profileJob: Job? = null

init {
fetchProfile(publicKey)
}
Expand All @@ -60,27 +65,32 @@ class AddContactViewModel @Inject constructor(
}

fun fetchProfile(publicKey: String) {
viewModelScope.launch {
profileJob?.cancel()
profileJob = viewModelScope.launch {
_uiState.update {
it.copy(
isLoading = true,
fetchedProfile = null,
error = null,
publicKeyInput = publicKey,
hasPublicPaymentEndpoint = false,
)
}
pubkyRepo.fetchContactProfile(publicKey)
.onSuccess { profile ->
val hasEndpoint = loadPaymentEndpoint(profile.publicKey)
currentCoroutineContext().ensureActive()
_uiState.update {
it.copy(
fetchedProfile = profile,
hasPublicPaymentEndpoint = hasEndpoint,
isLoading = false,
)
}
val hasEndpoint = loadPaymentEndpoint(profile.publicKey)
currentCoroutineContext().ensureActive()
_uiState.update { it.copy(hasPublicPaymentEndpoint = hasEndpoint) }
}
.onFailure { error ->
currentCoroutineContext().ensureActive()
_uiState.update { state ->
state.copy(
isLoading = false,
Expand Down Expand Up @@ -156,9 +166,10 @@ class AddContactViewModel @Inject constructor(
}

fun saveContact() {
if (_uiState.value.isLoading) return
val profile = _uiState.value.fetchedProfile ?: return
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
pubkyRepo.addContact(profile.publicKey, profile)
.onSuccess {
refreshContactPaykitLink(profile.publicKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ package to.bitkit.ui.screens.contacts

import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelStore
import app.cash.turbine.test
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.withContext
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
Expand All @@ -17,8 +22,11 @@ import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.PublicPaykitRepo
import to.bitkit.test.BaseUnitTest
import to.bitkit.usecases.RefreshContactPaykitLinkUseCase
import to.bitkit.utils.AppError
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

@OptIn(ExperimentalCoroutinesApi::class)
class AddContactViewModelTest : BaseUnitTest() {
Expand Down Expand Up @@ -81,6 +89,110 @@ class AddContactViewModelTest : BaseUnitTest() {
assertNull(sut.uiState.value.error)
}

@Test
fun `profile can be saved while public payment endpoint lookup is suspended`() = test {
val profile = PubkyProfile.placeholder(TEST_PUBLIC_KEY)
val endpoint = CompletableDeferred<Result<Boolean>>()
whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(profile))
whenever(publicPaykitRepo.hasPayablePublicEndpoint(TEST_PUBLIC_KEY)).doSuspendableAnswer { endpoint.await() }
whenever(pubkyRepo.addContact(TEST_PUBLIC_KEY, profile)).thenReturn(Result.success(Unit))
whenever(refreshContactPaykitLink(TEST_PUBLIC_KEY)).thenReturn(Result.success(Unit))
val sut = createSut()
try {
assertEquals(profile, sut.uiState.value.fetchedProfile)
assertFalse(sut.uiState.value.isLoading)
assertFalse(sut.uiState.value.hasPublicPaymentEndpoint)
sut.effects.test {
sut.saveContact()
assertEquals(AddContactEffect.ContactSaved(TEST_PUBLIC_KEY), awaitItem())
verify(pubkyRepo).addContact(TEST_PUBLIC_KEY, profile)
}
} finally {
endpoint.complete(Result.success(false))
}
}

@Test
fun `late endpoint result cannot replace a newer contact preview`() = test {
val oldProfile = PubkyProfile.placeholder(TEST_PUBLIC_KEY)
val newProfile = PubkyProfile.placeholder("pubky-new-contact")
val endpoint = CompletableDeferred<Result<Boolean>>()
whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(oldProfile))
whenever(pubkyRepo.fetchContactProfile(newProfile.publicKey)).thenReturn(Result.success(newProfile))
whenever(publicPaykitRepo.hasPayablePublicEndpoint(TEST_PUBLIC_KEY)).doSuspendableAnswer {
withContext(NonCancellable) { endpoint.await() }
}
whenever(publicPaykitRepo.hasPayablePublicEndpoint(newProfile.publicKey)).thenReturn(Result.success(false))
val sut = createSut()
sut.fetchProfile(newProfile.publicKey)
endpoint.complete(Result.success(true))
advanceUntilIdle()

assertEquals(newProfile, sut.uiState.value.fetchedProfile)
assertFalse(sut.uiState.value.hasPublicPaymentEndpoint)
assertFalse(sut.uiState.value.isLoading)
}

@Test
fun `leaving preview ignores a non cancellable endpoint response`() = test {
val profile = PubkyProfile.placeholder(TEST_PUBLIC_KEY)
val endpoint = CompletableDeferred<Result<Boolean>>()
whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(profile))
whenever(publicPaykitRepo.hasPayablePublicEndpoint(TEST_PUBLIC_KEY)).doSuspendableAnswer {
withContext(NonCancellable) { endpoint.await() }
}
val sut = createSut()
val store = ViewModelStore().apply { put("preview", sut) }
store.clear()
endpoint.complete(Result.success(true))
advanceUntilIdle()

assertEquals(profile, sut.uiState.value.fetchedProfile)
assertFalse(sut.uiState.value.hasPublicPaymentEndpoint)
}

@Test
fun `endpoint completion cannot clear an in flight save`() = test {
val profile = PubkyProfile.placeholder(TEST_PUBLIC_KEY)
val endpoint = CompletableDeferred<Result<Boolean>>()
val save = CompletableDeferred<Result<Unit>>()
whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(profile))
whenever(publicPaykitRepo.hasPayablePublicEndpoint(TEST_PUBLIC_KEY)).doSuspendableAnswer { endpoint.await() }
whenever(pubkyRepo.addContact(TEST_PUBLIC_KEY, profile)).doSuspendableAnswer { save.await() }
whenever(refreshContactPaykitLink(TEST_PUBLIC_KEY)).thenReturn(Result.success(Unit))
val sut = createSut()
sut.effects.test {
sut.saveContact()
sut.saveContact()
endpoint.complete(Result.success(true))
advanceUntilIdle()
try {
assertTrue(sut.uiState.value.isLoading)
assertTrue(sut.uiState.value.hasPublicPaymentEndpoint)
} finally {
save.complete(Result.success(Unit))
}
assertEquals(AddContactEffect.ContactSaved(TEST_PUBLIC_KEY), awaitItem())
verify(pubkyRepo).addContact(TEST_PUBLIC_KEY, profile)
}
}

@Test
fun `failed endpoint lookup keeps the loaded profile available to save`() = test {
val profile = PubkyProfile.placeholder(TEST_PUBLIC_KEY)
whenever(pubkyRepo.fetchContactProfile(TEST_PUBLIC_KEY)).thenReturn(Result.success(profile))
whenever(publicPaykitRepo.hasPayablePublicEndpoint(TEST_PUBLIC_KEY))
.thenReturn(Result.failure(AppError("Pubky unavailable")))

val sut = createSut()
advanceUntilIdle()

assertEquals(profile, sut.uiState.value.fetchedProfile)
assertFalse(sut.uiState.value.isLoading)
assertFalse(sut.uiState.value.hasPublicPaymentEndpoint)
assertNull(sut.uiState.value.error)
}

@Test
fun `saving emits saved contact key`() = test {
val profile = PubkyProfile.placeholder(TEST_PUBLIC_KEY)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1683.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Show contact profiles and allow saving while optional payment details are still loading.
18 changes: 18 additions & 0 deletions journeys/contacts/save-during-endpoint-discovery.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<journey name="save contact during endpoint discovery">
<description>
Requires a disposable authenticated Bitkit identity and an unsaved peer with a readable profile.
Delay only the peer's public payment-endpoint lookup after its profile has loaded; leave contact
writes available. The native component tests provide deterministic held-read coverage. A complete
service journey needs a compatible homeserver fault fixture and must be reported unrun without it.
No payment is sent. Opening the saved contact confirms the contact record, not payment-link readiness.
iOS titles this screen Contact Saved; Android titles it Contact.
</description>
<actions>
<action>Open Contacts and add the unsaved peer by its Pubky key</action>
<action>While payment-endpoint discovery is delayed, verify the peer profile and Save button appear without waiting for the endpoint response</action>
<action>Verify Pay is absent until a payable endpoint has been confirmed</action>
<action>Tap Save once and verify the saved contact details show the peer after the contact write succeeds</action>
<action>Return to Contacts and verify the peer appears only once</action>
<action>Restore endpoint access, reopen the saved contact and retry Pay if needed; do not send a payment or assume the saved confirmation means the link is ready</action>
</actions>
</journey>
Loading