diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index eb93d9c708..dab03495bb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -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 @@ -51,6 +54,8 @@ class AddContactViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() + private var profileJob: Job? = null + init { fetchProfile(publicKey) } @@ -60,10 +65,12 @@ 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, @@ -71,16 +78,19 @@ class AddContactViewModel @Inject constructor( } 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, @@ -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) diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt index 02de7ae0af..c4f4b519b2 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/AddContactViewModelTest.kt @@ -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 @@ -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() { @@ -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>() + 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>() + 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>() + 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>() + val save = CompletableDeferred>() + 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) diff --git a/changelog.d/next/1683.fixed.md b/changelog.d/next/1683.fixed.md new file mode 100644 index 0000000000..6e033f87d4 --- /dev/null +++ b/changelog.d/next/1683.fixed.md @@ -0,0 +1 @@ +Show contact profiles and allow saving while optional payment details are still loading. diff --git a/journeys/contacts/save-during-endpoint-discovery.xml b/journeys/contacts/save-during-endpoint-discovery.xml new file mode 100644 index 0000000000..a10d5eabc3 --- /dev/null +++ b/journeys/contacts/save-during-endpoint-discovery.xml @@ -0,0 +1,18 @@ + + + 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. + + + Open Contacts and add the unsaved peer by its Pubky key + While payment-endpoint discovery is delayed, verify the peer profile and Save button appear without waiting for the endpoint response + Verify Pay is absent until a payable endpoint has been confirmed + Tap Save once and verify the saved contact details show the peer after the contact write succeeds + Return to Contacts and verify the peer appears only once + 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 + +