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
45 changes: 22 additions & 23 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -931,43 +931,42 @@ class PubkyRepo @Inject constructor(
throw it
}

suspend fun importContacts(publicKeys: List<String>): Result<Unit> = runSuspendCatching {
suspend fun importContacts(profiles: List<PubkyProfile>): Result<Unit> = runSuspendCatching {
withContext(ioDispatcher) {
val imported = coroutineScope {
publicKeys.map { contactPk ->
val prefixedKey = contactPk.ensurePubkyPrefix()
async {
runSuspendCatching {
val profile = resolveContactProfile(prefixedKey).getOrThrow()
?: PubkyProfile.placeholder(prefixedKey)
pubkyService.saveContact(
prefixedKey,
profile.name,
relevantReceiverPaths(prefixedKey),
restorePrivateConnection = true,
)
profile
}.onFailure {
Logger.warn("Failed to import contact '${redacted(prefixedKey)}'", it, context = TAG)
}.getOrNull()
}
}.awaitAll().filterNotNull()
val imported = mutableListOf<PubkyProfile>()
val existing = _contacts.value.map { it.publicKey }.toMutableSet()
var firstError: Throwable? = null
for (profile in profiles.distinctBy { it.publicKey }) {
if (profile.publicKey in existing) continue
runSuspendCatching {
// The preview already resolved this profile. Receiver discovery runs during contact refresh.
pubkyService.saveContact(profile.publicKey, profile.name, restorePrivateConnection = true)
imported.add(profile)
existing.add(profile.publicKey)
}.onFailure {
firstError = firstError ?: it
Logger.warn("Failed to import contact '${redacted(profile.publicKey)}'", it, context = TAG)
}
}
updateContacts { current ->
val existing = current.map { it.publicKey }.toSet()
(current + imported.filter { it.publicKey !in existing })
val currentKeys = current.map { it.publicKey }.toSet()
(current + imported.filter { it.publicKey !in currentKeys })
.sortedBy { it.name.lowercase() }
}
markContactsLoaded()
Logger.info("Imported '${imported.size}' contacts", context = TAG)
firstError?.let { throw it }
Comment thread
ovitrif marked this conversation as resolved.
}
}

suspend fun prepareImport(): Result<Unit> = runSuspendCatching {
clearPendingImport()
val pk = requireNotNull(_publicKey.value) { "Not authenticated" }
withContext(ioDispatcher) {
val contactKeys = pubkyService.getContacts(pk)
val canonicalOwnKey = PubkyPublicKeyFormat.canonicalized(pk)
val contactKeys = pubkyService.getContacts(pk).filterNot {
canonicalOwnKey != null && PubkyPublicKeyFormat.canonicalized(it) == canonicalOwnKey
}
Logger.debug("Discovered '${contactKeys.size}' contacts for import", context = TAG)

val contacts = coroutineScope {
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import to.bitkit.services.CoreService
import to.bitkit.services.PaykitReceiverPaths
import to.bitkit.services.PaykitSdkService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import to.bitkit.utils.NetworkValidationHelper
import to.bitkit.utils.encodeToUrl
import java.util.Locale
Expand Down Expand Up @@ -294,6 +295,12 @@ class PublicPaykitRepo @Inject constructor(
publishMutex.withLock {
requireCurrentPublicKey()
val report = paykitSdkService.syncPublicEndpoints(desiredEndpoints)
report.failed.forEach { failure ->
Logger.warn(
"Failed to sync public Paykit endpoint '${failure.identifier}': ${failure.error}",
context = "PublicPaykitRepo",
)
}
if (report.failed.isNotEmpty()) throw PublicPaykitError.PublicationFailed
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ private fun Content(
SecondaryButton(
text = stringResource(R.string.contacts__import_select),
onClick = onClickSelect,
enabled = !uiState.isImporting,
modifier = Modifier.weight(1f),
)
PrimaryButton(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,26 +58,29 @@ class ContactImportOverviewViewModel @Inject constructor(
}

fun importAll() {
if (_uiState.value.isImporting) return
val contacts = _uiState.value.contacts
if (contacts.isEmpty()) return

_uiState.update { it.copy(isImporting = true) }
viewModelScope.launch {
_uiState.update { it.copy(isImporting = true) }
pubkyRepo.importContacts(contacts.map { it.publicKey })
.onSuccess {
pubkyRepo.clearPendingImport()
_uiState.update { it.copy(isImporting = false) }
_effects.emit(ContactImportOverviewEffect.ImportComplete)
}
.onFailure {
Logger.error("Failed to import all contacts", it, context = TAG)
_uiState.update { it.copy(isImporting = false) }
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.common__error),
description = it.message,
)
}
try {
pubkyRepo.importContacts(contacts)
.onSuccess {
pubkyRepo.clearPendingImport()
_effects.emit(ContactImportOverviewEffect.ImportComplete)
}
.onFailure {
Logger.error("Failed to import all contacts", it, context = TAG)
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.common__error),
description = it.message,
)
}
} finally {
_uiState.update { it.copy(isImporting = false) }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,30 +85,33 @@ class ContactImportSelectViewModel @Inject constructor(
}

fun importSelected() {
if (_uiState.value.isImporting) return
val selected = _uiState.value.contacts.filter { it.isSelected }
_uiState.update { it.copy(isImporting = true) }
viewModelScope.launch {
if (selected.isEmpty()) {
pubkyRepo.clearPendingImport()
_effects.emit(ContactImportSelectEffect.ImportComplete)
return@launch
}

_uiState.update { it.copy(isImporting = true) }
pubkyRepo.importContacts(selected.map { it.profile.publicKey })
.onSuccess {
try {
if (selected.isEmpty()) {
pubkyRepo.clearPendingImport()
_uiState.update { it.copy(isImporting = false) }
_effects.emit(ContactImportSelectEffect.ImportComplete)
return@launch
}
.onFailure {
Logger.error("Failed to import selected contacts", it, context = TAG)
_uiState.update { it.copy(isImporting = false) }
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.common__error),
description = it.message,
)
}

pubkyRepo.importContacts(selected.map { it.profile })
.onSuccess {
pubkyRepo.clearPendingImport()
_effects.emit(ContactImportSelectEffect.ImportComplete)
}
.onFailure {
Logger.error("Failed to import selected contacts", it, context = TAG)
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.common__error),
description = it.message,
)
}
} finally {
_uiState.update { it.copy(isImporting = false) }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ class PayContactsViewModel @Inject constructor(
}
}

private fun syncErrorMessage(error: Throwable): String = when (error) {
private fun syncErrorMessage(error: Throwable): String = when (
generateSequence(error) { it.cause }.filterIsInstance<PublicPaykitError>().firstOrNull()
) {
PublicPaykitError.InvalidPayload -> context.getString(R.string.profile__pay_contacts_error_invalid_payload)
PublicPaykitError.NoSupportedEndpoint -> context.getString(R.string.profile__pay_contacts_error_no_endpoint)
PublicPaykitError.SessionNotActive -> context.getString(R.string.profile__pay_contacts_error_session)
PublicPaykitError.WalletNotReady -> context.getString(R.string.profile__pay_contacts_error_wallet)
else -> context.getString(R.string.common__error_body)
else -> context.getString(R.string.profile__pay_contacts_error_retry)
}
}

Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@
<string name="profile__pay_contacts_description">Use Bitkit with your contacts to send payments directly, anytime, anywhere.</string>
<string name="profile__pay_contacts_error_invalid_payload">Payment endpoint data could not be prepared.</string>
<string name="profile__pay_contacts_error_no_endpoint">No supported payment endpoint is available.</string>
<string name="profile__pay_contacts_error_retry">Could not enable contact payments. Your saved contacts are unchanged. Please try again.</string>
<string name="profile__pay_contacts_error_session">Reconnect your Pubky profile to share payment data.</string>
<string name="profile__pay_contacts_error_wallet">Wallet is still starting. Try again in a moment.</string>
<string name="profile__pay_contacts_headline">Let your\ncontacts\n&lt;accent&gt;pay you&lt;/accent&gt;</string>
Expand Down
73 changes: 73 additions & 0 deletions app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,79 @@ class PubkyRepoTest : BaseUnitTest() {
adoptedSource = SharedPubkyContract.RING_SOURCE_PREFIX + VALID_SELF_KEY.removePrefix("pubky")
}

@Test
fun `import saves prepared profiles without network lookups and ignores duplicates`() = test {
val profiles = listOf(
PubkyProfile.placeholder(VALID_CONTACT_KEY_A).copy(name = "Alice"),
PubkyProfile.placeholder(VALID_CONTACT_KEY_B).copy(name = "Bob"),
)
for (profile in profiles) {
whenever(pubkyService.saveContact(profile.publicKey, profile.name, restorePrivateConnection = true))
.thenReturn(mock())
}
whenever(pubkyService.resolveContactProfile(any(), any())).thenAnswer { throw TestAppError("Offline") }
whenever(pubkyService.discoverRelevantReceiverPaths(any())).thenAnswer { throw TestAppError("Offline") }

val result = sut.importContacts(profiles + profiles)

assertTrue(result.isSuccess)
assertEquals(profiles, sut.contacts.value)
for (profile in profiles) {
verify(pubkyService).saveContact(profile.publicKey, profile.name, restorePrivateConnection = true)
}
verify(pubkyService, never()).resolveContactProfile(any(), any())
verify(pubkyService, never()).discoverRelevantReceiverPaths(any())
}

@Test
fun `failed import keeps successful contacts and retry saves only missing contacts`() = test {
val alice = PubkyProfile.placeholder(VALID_CONTACT_KEY_A).copy(name = "Alice")
val bob = PubkyProfile.placeholder(VALID_CONTACT_KEY_B).copy(name = "Bob")
whenever(pubkyService.saveContact(alice.publicKey, alice.name, restorePrivateConnection = true))
.thenReturn(mock())
whenever(pubkyService.saveContact(bob.publicKey, bob.name, restorePrivateConnection = true))
.thenAnswer { throw TestAppError("Storage unavailable") }.thenReturn(mock())

assertTrue(sut.importContacts(listOf(alice, bob)).isFailure)
assertEquals(listOf(alice), sut.contacts.value)
assertTrue(sut.importContacts(listOf(alice, bob)).isSuccess)
assertEquals(listOf(alice, bob), sut.contacts.value)
verify(pubkyService).saveContact(alice.publicKey, alice.name, restorePrivateConnection = true)
verify(pubkyService, times(2)).saveContact(bob.publicKey, bob.name, restorePrivateConnection = true)
}

@Test
fun `prepareImport excludes own key and imports remaining follows`() = test {
authenticateForTesting(publicKey = VALID_CONTACT_KEY_A)
val ownProfile = checkNotNull(sut.profile.value)
val ownKeys = listOf(
VALID_CONTACT_KEY_A,
VALID_CONTACT_KEY_A.removePrefix("pubky"),
NON_CANONICAL_CONTACT_KEY_A,
NON_CANONICAL_CONTACT_KEY_A.removePrefix("pubky"),
)
val alice = PubkyProfile.placeholder(VALID_CONTACT_KEY_B).copy(name = "Alice")
whenever(pubkyService.getContacts(VALID_CONTACT_KEY_A)).thenReturn(ownKeys + VALID_CONTACT_KEY_B)
whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true))
.thenReturn(createResolution(VALID_CONTACT_KEY_B, paykitProfile = createPaykitProfile("Alice")))
whenever(pubkyService.saveContact(alice.publicKey, alice.name, restorePrivateConnection = true))
.thenReturn(mock())
whenever(pubkyService.saveContact(VALID_CONTACT_KEY_A, ownProfile.name, restorePrivateConnection = true))
.thenAnswer { throw TestAppError("Cannot save own identity") }

assertTrue(sut.prepareImport().isSuccess)
assertEquals(ownProfile, sut.pendingImportProfile.value)
assertEquals(listOf(alice), sut.pendingImportContacts.value)
assertTrue(sut.importContacts(sut.pendingImportContacts.value).isSuccess)
assertEquals(listOf(alice), sut.contacts.value)

whenever(pubkyService.getContacts(VALID_CONTACT_KEY_A)).thenReturn(ownKeys)

assertTrue(sut.prepareImport().isSuccess)
assertEquals(ownProfile, sut.pendingImportProfile.value)
assertTrue(sut.pendingImportContacts.value.isEmpty())
}

@Test
fun `initial state should have no public key`() = test {
assertNull(sut.publicKey.value)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,48 @@
package to.bitkit.ui.screens.contacts

import android.content.Context
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.Test
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import to.bitkit.models.PubkyProfile
import to.bitkit.repositories.PubkyRepo
import to.bitkit.test.BaseUnitTest
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

@OptIn(ExperimentalCoroutinesApi::class)
class ContactImportOverviewViewModelTest : BaseUnitTest() {
private val context: Context = mock()
private val pubkyRepo: PubkyRepo = mock()

@Test
fun `pending import blocks duplicate requests and clears progress after cancellation`() = test {
val contacts = listOf(createProfile(publicKey = "pubkyalice"))
stubPendingImport(createProfile(publicKey = "pubkyself"), contacts)
val pending = CompletableDeferred<Result<Unit>>()
whenever(pubkyRepo.importContacts(contacts)).doSuspendableAnswer { pending.await() }
val sut = createSut()
advanceUntilIdle()

sut.importAll()
sut.importAll()
advanceUntilIdle()
assertTrue(sut.uiState.value.isImporting)
verify(pubkyRepo).importContacts(contacts)

pending.cancel()
advanceUntilIdle()
assertFalse(sut.uiState.value.isImporting)
}

@Test
fun `missing pending import redirects to pay contacts`() = test {
stubPendingImport(profile = null, contacts = emptyList())
Expand All @@ -34,7 +57,7 @@ class ContactImportOverviewViewModelTest : BaseUnitTest() {
fun `importAll clears pending import and completes`() = test {
val contacts = listOf(createProfile(publicKey = "pubkyalice"), createProfile(publicKey = "pubkybob"))
stubPendingImport(profile = createProfile(publicKey = "pubkyself"), contacts = contacts)
whenever(pubkyRepo.importContacts(contacts.map { it.publicKey })).thenReturn(Result.success(Unit))
whenever(pubkyRepo.importContacts(contacts)).thenReturn(Result.success(Unit))
val sut = createSut()

val effects = mutableListOf<ContactImportOverviewEffect>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class ContactImportSelectViewModelTest : BaseUnitTest() {
fun `importSelected success clears pending import and completes`() = test {
val contacts = listOf(createProfile(publicKey = "pubkyalice"), createProfile(publicKey = "pubkybob"))
stubPendingImport(profile = createProfile(publicKey = "pubkyself"), contacts = contacts)
whenever(pubkyRepo.importContacts(contacts.map { it.publicKey })).thenReturn(Result.success(Unit))
whenever(pubkyRepo.importContacts(contacts)).thenReturn(Result.success(Unit))
val sut = createSut()

val effects = mutableListOf<ContactImportSelectEffect>()
Expand Down
Loading
Loading