diff --git a/app/src/main/java/to/bitkit/data/PubkyImageCacheEpoch.kt b/app/src/main/java/to/bitkit/data/PubkyImageCacheEpoch.kt new file mode 100644 index 0000000000..530a58d79c --- /dev/null +++ b/app/src/main/java/to/bitkit/data/PubkyImageCacheEpoch.kt @@ -0,0 +1,22 @@ +package to.bitkit.data + +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Counts clears of the Pubky image disk cache. [PubkyImageFetcher] reads it before a network fetch and commits + * the fetched image only while it is unchanged, so a fetch in flight across a clear cannot re-populate the + * cleared directory, and [PubkyImageFailureCache] forgets every failed fetch once it changes. Advance it before + * clearing the directory. + */ +@Singleton +class PubkyImageCacheEpoch @Inject constructor() { + private val epoch = AtomicLong(0L) + + fun current(): Long = epoch.get() + + fun advance() { + epoch.incrementAndGet() + } +} diff --git a/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt b/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt index 7bc1003c32..71f9315d4d 100644 --- a/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt +++ b/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt @@ -4,15 +4,24 @@ import coil3.ImageLoader import coil3.Uri import coil3.decode.DataSource import coil3.decode.ImageSource +import coil3.disk.DiskCache import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.SourceFetchResult import coil3.request.Options +import com.synonym.paykit.PaykitException import okio.Buffer import org.json.JSONObject +import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.services.PubkyFileNotFoundError import to.bitkit.services.PubkyService +import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds private const val TAG = "PubkyImageFetcher" private const val PUBKY_SCHEME = "pubky://" @@ -20,35 +29,146 @@ private const val PUBKY_SCHEME = "pubky://" /** Maximum successful response body accepted for a displayed Pubky image. */ internal const val PUBKY_IMAGE_MAX_BYTES = 1_048_576uL +/** How long a Pubky image whose fetch failed for a reason that can pass, such as a network error, is not fetched. */ +internal val PUBKY_IMAGE_TRANSIENT_FAILURE_TTL = 60.seconds + class PubkyImageFetcher( private val uri: String, private val options: Options, private val pubkyService: PubkyService, + private val diskCache: DiskCache?, + private val cacheEpoch: PubkyImageCacheEpoch, + private val failures: PubkyImageFailureCache, ) : Fetcher { + private val diskCacheKey = options.diskCacheKey ?: uri override suspend fun fetch(): FetchResult { - val data = pubkyService.fetchFile(uri, PUBKY_IMAGE_MAX_BYTES) - val blobData = resolveImageData(data) - val source = ImageSource(Buffer().apply { write(blobData) }, options.fileSystem) + readFromDiskCache()?.let { return it } + failures.recentFailure(uri)?.let { throw PubkyImageRecentlyFailedError(it) } + + val epoch = cacheEpoch.current() + val data = runSuspendCatching { pubkyService.fetchFile(uri, PUBKY_IMAGE_MAX_BYTES) } + .onFailure { failures.remember(uri, it, epoch) } + .getOrThrow() + val image = resolveImageData(data, epoch) + if (image.isCacheable) writeToDiskCache(image.bytes, epoch) + val source = ImageSource(Buffer().apply { write(image.bytes) }, options.fileSystem) return SourceFetchResult(source, null, dataSource = DataSource.NETWORK) } - private suspend fun resolveImageData(data: ByteArray): ByteArray = runSuspendCatching { - val json = JSONObject(String(data)) - val src = json.optString("src", "") - if (src.isNotEmpty() && src.startsWith(PUBKY_SCHEME)) { - Logger.debug("Found file descriptor, fetching blob from '$src'", context = TAG) - pubkyService.fetchFile(src, PUBKY_IMAGE_MAX_BYTES) - } else { - data + private suspend fun readFromDiskCache(): FetchResult? { + if (!options.diskCachePolicy.readEnabled) return null + val cache = diskCache ?: return null + val snapshot = runSuspendCatching { cache.openSnapshot(diskCacheKey) } + .onFailure { Logger.warn("Failed to read pubky image from disk cache", it, context = TAG) } + .getOrNull() + ?: return null + val source = ImageSource(snapshot.data, cache.fileSystem, diskCacheKey, snapshot) + return SourceFetchResult(source, null, dataSource = DataSource.DISK) + } + + private suspend fun writeToDiskCache(bytes: ByteArray, epoch: Long) { + if (!options.diskCachePolicy.writeEnabled) return + val cache = diskCache ?: return + val editor = runSuspendCatching { cache.openEditor(diskCacheKey) } + .onFailure { Logger.warn("Failed to open pubky image disk cache editor", it, context = TAG) } + .getOrNull() + ?: return + runCatching { + cache.fileSystem.write(editor.data) { write(bytes) } + // The editor is open, so a clear after this check makes evictAll() zombie the entry and drop the commit. + if (cacheEpoch.current() == epoch) editor.commit() else editor.abort() + }.onFailure { + runCatching { editor.abort() } + if (it is CancellationException) throw it + Logger.warn("Failed to cache pubky image", it, context = TAG) } - }.getOrDefault(data) + } + + private suspend fun resolveImageData(data: ByteArray, epoch: Long): PubkyImageData { + val descriptor = runSuspendCatching { JSONObject(String(data)) }.getOrNull() + ?: return PubkyImageData(data, isCacheable = true) + val src = descriptor.optString("src", "") + if (!src.startsWith(PUBKY_SCHEME)) return PubkyImageData(data, isCacheable = false) + + Logger.debug("Found file descriptor, fetching blob from '${PubkyPublicKeyFormat.redacted(src)}'", context = TAG) + return runSuspendCatching { pubkyService.fetchFile(src, PUBKY_IMAGE_MAX_BYTES) } + .map { PubkyImageData(it, isCacheable = true) } + .onFailure { + Logger.warn("Failed to fetch pubky image blob", it, context = TAG) + failures.remember(uri, it, epoch) + } + .getOrDefault(PubkyImageData(data, isCacheable = false)) + } + + class Factory( + private val pubkyService: PubkyService, + private val cacheEpoch: PubkyImageCacheEpoch, + clock: Clock, + ) : Fetcher.Factory { + private val failures = PubkyImageFailureCache(cacheEpoch, clock) - class Factory(private val pubkyService: PubkyService) : Fetcher.Factory { override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { val uri = data.toString() if (!uri.startsWith(PUBKY_SCHEME)) return null - return PubkyImageFetcher(uri, options, pubkyService) + return PubkyImageFetcher(uri, options, pubkyService, imageLoader.diskCache, cacheEpoch, failures) + } + } +} + +/** + * Remembers failed Pubky image fetches by URI, so an image that cannot load is not fetched again each time it is + * shown. A missing file, a file over [PUBKY_IMAGE_MAX_BYTES] or another invalid response is remembered until the + * image cache is next cleared, and any other failure for [PUBKY_IMAGE_TRANSIENT_FAILURE_TTL]. A clear of the image + * cache, which advances [PubkyImageCacheEpoch] on sign-out and identity change, forgets every failure, and a failure + * of a fetch that a clear overtook is not remembered. For a file descriptor, a failed blob fetch counts as a failure + * of the descriptor URI. + */ +class PubkyImageFailureCache( + private val cacheEpoch: PubkyImageCacheEpoch, + private val clock: Clock, +) { + private val lock = Any() + private val failures = mutableMapOf() + private var failuresEpoch = cacheEpoch.current() + + fun recentFailure(uri: String): Throwable? = synchronized(lock) { + forgetIfCleared() + val failure = failures[uri] ?: return null + if (failure.expiresAtMillis != null && clock.nowMs() >= failure.expiresAtMillis) { + failures.remove(uri) + return null + } + failure.error + } + + fun remember(uri: String, error: Throwable, epoch: Long) { + synchronized(lock) { + forgetIfCleared() + if (epoch != failuresEpoch) return + val expiresAtMillis = if (error.isPermanentImageFailure()) { + null + } else { + clock.nowMs() + PUBKY_IMAGE_TRANSIENT_FAILURE_TTL.inWholeMilliseconds + } + failures[uri] = Failure(error, expiresAtMillis) } } + + private fun forgetIfCleared() { + val epoch = cacheEpoch.current() + if (epoch == failuresEpoch) return + failures.clear() + failuresEpoch = epoch + } + + private fun Throwable.isPermanentImageFailure(): Boolean = generateSequence(this) { it.cause }.any { + it is PubkyFileNotFoundError || it is PaykitException.NotFound || it is PaykitException.Protocol + } + + private class Failure(val error: Throwable, val expiresAtMillis: Long?) } + +class PubkyImageRecentlyFailedError(cause: Throwable) : AppError("Pubky image fetch failed recently", cause) + +private class PubkyImageData(val bytes: ByteArray, val isCacheable: Boolean) diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index 90348d217c..43e27f3409 100644 --- a/app/src/main/java/to/bitkit/data/PubkyStore.kt +++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt @@ -36,7 +36,19 @@ class PubkyStore @Inject constructor( @Serializable data class PubkyStoreData( val ownerPublicKey: String? = null, + /** Public key whose profile [cachedName] and [cachedImageUri] were cached from. */ + val cachedProfileOwner: String? = null, val cachedName: String? = null, val cachedImageUri: String? = null, val contactProfileOverrides: Map = emptyMap(), +) { + fun cachedProfile(): PubkyCachedProfile? = cachedProfileOwner?.let { + PubkyCachedProfile(publicKey = it, name = cachedName.orEmpty(), imageUri = cachedImageUri) + } +} + +data class PubkyCachedProfile( + val publicKey: String, + val name: String, + val imageUri: String?, ) diff --git a/app/src/main/java/to/bitkit/di/ImageModule.kt b/app/src/main/java/to/bitkit/di/ImageModule.kt index 8ad67091eb..eb264a1449 100644 --- a/app/src/main/java/to/bitkit/di/ImageModule.kt +++ b/app/src/main/java/to/bitkit/di/ImageModule.kt @@ -11,9 +11,11 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import to.bitkit.data.PubkyImageCacheEpoch import to.bitkit.data.PubkyImageFetcher import to.bitkit.services.PubkyService import javax.inject.Singleton +import kotlin.time.Clock @Module @InstallIn(SingletonComponent::class) @@ -24,9 +26,11 @@ object ImageModule { fun provideImageLoader( @ApplicationContext context: Context, pubkyService: PubkyService, + cacheEpoch: PubkyImageCacheEpoch, + clock: Clock, ): ImageLoader = ImageLoader.Builder(context) .crossfade(true) - .components { add(PubkyImageFetcher.Factory(pubkyService)) } + .components { add(PubkyImageFetcher.Factory(pubkyService, cacheEpoch, clock)) } .memoryCache { MemoryCache.Builder() .maxSizePercent(context, percent = 0.15) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index bf172a52d4..8d97c66ed6 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -46,6 +46,7 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.satsToMsat import to.bitkit.services.PaykitPaymentRequestProposalTerms import to.bitkit.services.PaykitPaymentRequestRecurrenceTerms +import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.utils.AppError @@ -458,7 +459,7 @@ class PaykitPaymentRequestRepo @Inject constructor( val context = targetContext(savedPublicKeys, expectedIdentity) if (!force && context == cachedTargetContext) return@discovery val previousTargets = _eligibleTargets.value.associateBy { it.publicKey } - val discovery = context?.let { eligibleTargets(it, previousTargets) } + val discovery = context?.let { eligibleTargets(it, previousTargets, PaykitReadLane.Bulk) } ?: PaykitPaymentRequestTargetDiscovery(emptyList(), isComplete = true) operationMutex.withLock operation@{ if (!isCurrentState(generation, expectedIdentity)) return@operation @@ -1085,13 +1086,14 @@ class PaykitPaymentRequestRepo @Inject constructor( private suspend fun eligibleTargets( context: PaykitPaymentRequestTargetContext, previousTargets: Map = emptyMap(), + lane: PaykitReadLane = PaykitReadLane.Interactive, ): PaykitPaymentRequestTargetDiscovery { var isComplete = true val failedPublicKeys = mutableSetOf() val targets = context.savedPublicKeys.mapNotNull { publicKey -> val linked = context.linkedReceiverPaths[publicKey] ?: return@mapNotNull null val lookup = withTimeoutOrNull(TARGET_DISCOVERY_TIMEOUT) { - runSuspendCatching { paykitSdkService.paymentRequestReceiverPaths(publicKey) } + runSuspendCatching { paykitSdkService.paymentRequestReceiverPaths(publicKey, lane) } } if (lookup == null) { isComplete = false diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 550f2e90a9..456818145e 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -43,6 +43,7 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution +import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -213,7 +214,7 @@ class PrivatePaykitRepo @Inject constructor( ): Result = withContext(serializedDispatcher) { runSuspendCatching { if (!canPublishPrivateEndpoints()) { - prepareRelevantPrivateLinksIfAvailable(knownSavedContactKeys, reason) + prepareRelevantPrivateLinksIfAvailable(knownSavedContactKeys.toList(), reason) return@runSuspendCatching } publishLocalEndpoints( @@ -638,6 +639,7 @@ class PrivatePaykitRepo @Inject constructor( publishLocalEndpoints( publicKeys = listOf(publicKey), reason = "payment", + lane = PaykitReadLane.Interactive, ).onFailure { Logger.warn( "Failed to refresh private Paykit endpoints before payment for '${redacted(publicKey)}'", @@ -788,6 +790,7 @@ class PrivatePaykitRepo @Inject constructor( reason: String, forceRefreshLightning: Boolean = false, requireImmediatePublication: Boolean = false, + lane: PaykitReadLane = PaykitReadLane.Bulk, ): Result = withContext(serializedDispatcher) { runSuspendCatching { val keys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct() @@ -804,6 +807,7 @@ class PrivatePaykitRepo @Inject constructor( publicKeys = keys, reason = reason, forceRefreshLightning = forceRefreshLightning, + lane = lane, ) if (preparation.updates.isEmpty()) { @@ -837,6 +841,7 @@ class PrivatePaykitRepo @Inject constructor( publicKeys: Collection, reason: String, forceRefreshLightning: Boolean, + lane: PaykitReadLane, ): PrivatePublicationPreparation { var firstError: Throwable? = null var receiverPathSelectionError: Throwable? = null @@ -847,7 +852,7 @@ class PrivatePaykitRepo @Inject constructor( firstError = linkedReceiverPathsSnapshot.error for (publicKey in publicKeys) { - val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } + val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey, lane) } .onFailure { firstError = firstError ?: it Logger.warn( @@ -856,7 +861,7 @@ class PrivatePaykitRepo @Inject constructor( context = TAG, ) }.getOrNull() ?: continue - val receiverPathSelection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) + val receiverPathSelection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths, lane) val linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths receiverPathSelection.error?.let { @@ -901,7 +906,7 @@ class PrivatePaykitRepo @Inject constructor( val retryKeys = mutableListOf() for (publicKey in publicKeys) { - val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } + val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey, PaykitReadLane.Bulk) } .onFailure { Logger.warn( "Failed to read saved Paykit receivers for '${redacted(publicKey)}' during '$reason'", @@ -909,7 +914,7 @@ class PrivatePaykitRepo @Inject constructor( context = TAG, ) }.getOrNull() ?: continue - val selection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths) + val selection = paykitSdkService.privateReceiverPathSelection(publicKey, receiverPaths, PaykitReadLane.Bulk) selection.error?.let { Logger.warn( "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", @@ -1490,12 +1495,12 @@ class PrivatePaykitRepo @Inject constructor( ) } - private suspend fun receiverPathsForSavedContact(publicKey: String): List { + private suspend fun receiverPathsForSavedContact(publicKey: String, lane: PaykitReadLane): List { val record = paykitSdkService.contactRecord(publicKey) val savedPaths = supportedReceiverPaths(record?.receiverPaths.orEmpty()) return runSuspendCatching { - val discoveredPaths = pubkyService.discoverRelevantReceiverPaths(publicKey) + val discoveredPaths = pubkyService.discoverRelevantReceiverPaths(publicKey, lane) val currentRecord = paykitSdkService.contactRecord(publicKey) ?: return@runSuspendCatching savedPaths val currentSavedPaths = supportedReceiverPaths(currentRecord.receiverPaths) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 97da3d121b..d0395fc7d0 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -4,7 +4,7 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import coil3.ImageLoader import com.synonym.paykit.ContactProfileResolution -import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.ContactRecord import com.synonym.paykit.PubkyAuthCompanionClaim import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -13,10 +13,15 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -27,11 +32,15 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.job +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.async.appScope +import to.bitkit.data.PubkyCachedProfile +import to.bitkit.data.PubkyImageCacheEpoch import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore import to.bitkit.data.hasPaykitState @@ -42,6 +51,7 @@ import to.bitkit.data.sharedpubky.SharedPubkyContract import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.isPaykitIdentityError +import to.bitkit.ext.nowMs import to.bitkit.ext.runSuspendCatching import to.bitkit.models.HomegateResponse import to.bitkit.models.PubkyAuthClaim @@ -52,14 +62,22 @@ import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 +import to.bitkit.services.PaykitReadLane +import to.bitkit.services.PaykitReadTimeoutError import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton import kotlin.math.min +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime sealed class PubkyContactError(message: String) : AppError(message) { data object AlreadyExists : PubkyContactError("Contact already exists") @@ -71,8 +89,24 @@ sealed class PubkyContactError(message: String) : AppError(message) { private fun Throwable.containsActiveSubscriptionError(): Boolean = generateSequence(this) { it.cause }.any { it is PubkyContactError.ActiveSubscription } +private fun Throwable.isPaykitReadTimeout(): Boolean = + generateSequence(this) { it.cause }.any { it is PaykitReadTimeoutError } + data object PubkyAlreadySignedInError : AppError("Already signed in") +/** Whether a Pubky identity is ready to use once restores already under way have finished. */ +enum class PubkyIdentityReadiness { + /** A session is active. */ + Ready, + + /** No identity is saved on this device. */ + Missing, + + /** An identity is saved, or could not be checked, but its session could not be restored yet. */ + Unavailable, +} + +@OptIn(ExperimentalTime::class) @Suppress("TooManyFunctions", "LargeClass", "LongParameterList") @Singleton class PubkyRepo @Inject constructor( @@ -81,9 +115,11 @@ class PubkyRepo @Inject constructor( private val keychain: Keychain, private val sharedPubkyClient: SharedPubkyClient, private val imageLoader: ImageLoader, + private val imageCacheEpoch: PubkyImageCacheEpoch, private val pubkyStore: PubkyStore, private val settingsStore: SettingsStore, private val httpClient: HttpClient, + private val clock: Clock, ) { companion object { private const val TAG = "PubkyRepo" @@ -91,6 +127,15 @@ class PubkyRepo @Inject constructor( private const val PUBKY_SCHEME = "pubky://" private const val AVATAR_MAX_SIZE = 400 private const val AVATAR_QUALITY = 80 + + /** Longest one follow's profile read in the import preview may run once it has a read slot. */ + internal val IMPORT_FOLLOW_LOOKUP_TIMEOUT = 10.seconds + + /** How long a contact profile resolved in this session is shown without being looked up again. */ + internal val CONTACT_PROFILE_FRESHNESS = 10.minutes + + /** Shortest time between two contact list updates of a background profile refresh. */ + internal val CONTACT_REFRESH_BATCH_WINDOW = 300.milliseconds } private val scope = appScope(ioDispatcher, TAG) @@ -100,8 +145,13 @@ class PubkyRepo @Inject constructor( private val loadContactsMutex = Mutex() private val contactsLock = Any() private var contactsRevision = 0L + private val sessionContactProfiles = mutableMapOf() + private var sessionContactProfilesOwner: String? = null + private var contactProfileRefresh: ContactProfileRefresh? = null + private val contactScreenLookups = mutableMapOf() private val adoptedSourceCheckMutex = Mutex() private val adoptionMutex = Mutex() + private val profileWriteGeneration = AtomicLong(0L) private var isServiceInitialized = false private val _profile = MutableStateFlow(null) @@ -140,6 +190,16 @@ class PubkyRepo @Inject constructor( private val _pendingImportContacts = MutableStateFlow>(emptyList()) val pendingImportContacts: StateFlow> = _pendingImportContacts.asStateFlow() + private val activeContactImports = MutableStateFlow(0) + val isImportingContacts: StateFlow = activeContactImports.map { it > 0 } + .stateIn(scope, SharingStarted.Eagerly, false) + + private val _contactImportVersion = MutableStateFlow(0L) + val contactImportVersion: StateFlow = _contactImportVersion.asStateFlow() + + private val _contactImportFailure = MutableStateFlow(null) + val contactImportFailure: StateFlow = _contactImportFailure.asStateFlow() + private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() @@ -167,12 +227,69 @@ class PubkyRepo @Inject constructor( profile?.imageUrl ?: cached.cachedImageUri }.stateIn(scope, SharingStarted.Eagerly, null) + val cachedProfile: StateFlow = pubkyStore.data.map { it.cachedProfile() } + .stateIn(scope, SharingStarted.Eagerly, null) + private sealed interface InitResult { data object NoSession : InitResult data class Restored(val publicKey: String) : InitResult data object RestorationFailed : InitResult } + private data class SavedContact( + val profile: PubkyProfile, + val label: String?, + val needsRefresh: Boolean, + val showsLabelOnly: Boolean = false, + ) + + private class SessionContactProfile(val profile: PubkyProfile, private val resolvedAtMillis: Long) { + fun isFresh(now: Long): Boolean = + now - resolvedAtMillis in 0 until CONTACT_PROFILE_FRESHNESS.inWholeMilliseconds + } + + private class RefreshedContact(val contact: SavedContact, val profile: PubkyProfile) + + private class ContactProfileRefresh( + val owner: String, + contacts: List, + scope: CoroutineScope, + lookUp: suspend (ContactProfileRefresh, SavedContact) -> Unit, + onFinished: (ContactProfileRefresh) -> Unit, + ) { + private val keys = contacts.mapTo(mutableSetOf()) { it.profile.publicKey } + private val labelOnly = contacts.filter { it.showsLabelOnly }.associateBy { it.profile.publicKey } + + val job = SupervisorJob(scope.coroutineContext.job) + val pendingResults = mutableMapOf() + var scheduledApply: Job? = null + private val bulkLookups = contacts.associate { + it.profile.publicKey to scope.launch(job, CoroutineStart.LAZY) { lookUp(this@ContactProfileRefresh, it) } + } + private val finish = scope.launch(job, CoroutineStart.LAZY) { + bulkLookups.values.joinAll() + onFinished(this@ContactProfileRefresh) + } + + fun start() { + bulkLookups.values.forEach { it.start() } + finish.start() + job.complete() + } + + fun covers(other: ContactProfileRefresh): Boolean = + job.isActive && owner == other.owner && keys.containsAll(other.keys) + + fun takeOver(owner: String, publicKey: String, isShown: (SavedContact) -> Boolean): SavedContact? { + if (this.owner != owner) return null + val contact = labelOnly[publicKey]?.takeIf(isShown) ?: return null + val bulkLookup = bulkLookups.getValue(publicKey).takeUnless { it.isCompleted || it.isCancelled } + ?: return null + bulkLookup.cancel() + return contact + } + } + private val initializationReady = CompletableDeferred() init { @@ -202,15 +319,7 @@ class PubkyRepo @Inject constructor( suspend fun restoreSessionIfNeeded() = withContext(ioDispatcher) { awaitInitialization() - val restored = initializeMutex.withLock { - if (_publicKey.value != null) return@withLock false - runSuspendCatching { - val hasIdentity = hasIdentity() - _identityRefreshVersion.update { it + 1 } - hasIdentity && initializeSession(notifyFailure = false) - }.onFailure { Logger.warn("Failed to retry paykit session restoration", it, context = TAG) } - .getOrDefault(false) - } + val restored = initializeMutex.withLock { restoreSessionLocked() } if (restored) { loadProfile() loadContacts() @@ -218,6 +327,41 @@ class PubkyRepo @Inject constructor( restored } + /** + * Waits until a saved identity can be used. When no session is active, it retries the restore once, after any + * resume retry, adoption, identity creation or backup restore already under way, so an earlier success is never + * restored twice. The retry runs in the repository scope, so cancelling the caller does not interrupt it, and the + * profile and contacts it loads are not awaited. + */ + suspend fun awaitIdentityReady(): PubkyIdentityReadiness = withContext(ioDispatcher) { + awaitInitialization() + if (_publicKey.value != null) return@withContext PubkyIdentityReadiness.Ready + scope.async { + val restored = initializeMutex.withLock { restoreSessionLocked() } + if (restored) { + scope.launch { + loadProfile() + loadContacts() + } + } + }.await() + when { + _publicKey.value != null -> PubkyIdentityReadiness.Ready + runSuspendCatching { hasIdentity() }.getOrNull() == false -> PubkyIdentityReadiness.Missing + else -> PubkyIdentityReadiness.Unavailable + } + } + + private suspend fun restoreSessionLocked(): Boolean { + if (_publicKey.value != null) return false + return runSuspendCatching { + val hasIdentity = hasIdentity() + _identityRefreshVersion.update { it + 1 } + hasIdentity && initializeSession(notifyFailure = false) + }.onFailure { Logger.warn("Failed to retry paykit session restoration", it, context = TAG) } + .getOrDefault(false) + } + private suspend fun initializeSession(notifyFailure: Boolean = true): Boolean { _isRestoringSession.update { true } try { @@ -368,7 +512,15 @@ class PubkyRepo @Inject constructor( } } - suspend fun adoptRingIdentity(pubky: String): Result = withContext(ioDispatcher) { + /** + * Adopts the Ring identity [pubky]. [knownProfile] is read once sign-in completes, and a profile it returns that + * matches the signed-in key is used instead of resolving the profile again; it must return only a profile that was + * found, never the result of a failed or empty lookup. + */ + suspend fun adoptRingIdentity( + pubky: String, + knownProfile: () -> PubkyProfile? = { null }, + ): Result = withContext(ioDispatcher) { val reference = "${SharedPubkyContract.RING_SOURCE_PREFIX}$pubky" adoptionMutex.withLock { var sessionInstalled = false @@ -394,12 +546,20 @@ class PubkyRepo @Inject constructor( prefixedPublicKey } - loadProfile() + val handoffProfile = knownProfile() + ?.takeIf { PubkyPublicKeyFormat.matches(it.publicKey, publicKey) } + ?.copy(publicKey = publicKey) + if (handoffProfile == null) loadProfile() loadContacts() initializeMutex.withLock { check(_publicKey.value == publicKey) { "Adopted Pubky identity changed before setup completed" } - val hasProfile = _profile.value?.publicKey == publicKey + handoffProfile?.let { profile -> + setProfile(profile) + runSuspendCatching { cacheMetadata(profile) } + .onFailure { Logger.warn("Failed to cache adopted profile", it, context = TAG) } + } + val hasProfile = handoffProfile != null || _profile.value?.publicKey == publicKey runSuspendCatching { settingsStore.setPubkyProfileSetupPending(!hasProfile) } .onFailure { Logger.warn("Failed to save pending profile setup", it, context = TAG) } completed = true @@ -463,9 +623,11 @@ class PubkyRepo @Inject constructor( private suspend fun clearProfileIfIdentityChanged(publicKey: String) { if (_publicKey.value == publicKey) return + clearPubkyImageDiskCache() _contactsLoadVersion.update { 0L } - _profile.update { null } + setProfile(null) _contacts.update { emptyList() } + clearSessionContactProfiles() clearPendingImport() } @@ -497,21 +659,27 @@ class PubkyRepo @Inject constructor( loadProfileMutex.unlock() return } + val writeGeneration = profileWriteGeneration.get() + val isCurrentLoad = { _publicKey.value == pk && profileWriteGeneration.get() == writeGeneration } _isLoadingProfile.update { true } try { runSuspendCatching { withContext(ioDispatcher) { - resolveContactProfile(pk).getOrThrow() + resolveContactProfile(pk, retry = true).getOrThrow() ?: throw AppError("Profile not found") } }.onSuccess { loadedProfile -> - if (_publicKey.value != pk) { + var isCurrent = false + _profile.update { + isCurrent = isCurrentLoad() + if (isCurrent) loadedProfile else it + } + if (!isCurrent) { Logger.debug("Skipped stale profile load for '${redacted(pk)}'", context = TAG) return@onSuccess } - _profile.update { loadedProfile } - cacheMetadata(loadedProfile) + cacheMetadata(loadedProfile, isCurrentLoad) }.onFailure { Logger.error("Failed to load profile", it, context = TAG) } @@ -521,11 +689,16 @@ class PubkyRepo @Inject constructor( } } - suspend fun fetchRemoteProfile(publicKey: String): Result = runSuspendCatching { - withContext(ioDispatcher) { - resolveContactProfile(publicKey).getOrThrow() - } - } + suspend fun fetchRemoteProfile(publicKey: String): Result = + resolveContactProfile(publicKey, retry = true) + + /** + * Resolves [publicKey]'s profile once, without retrying, for display only. A null or failed result is not proof + * that the profile does not exist. + */ + suspend fun fetchDisplayProfile(publicKey: String): Result = + resolveContactProfile(publicKey, retry = false) + .onFailure { Logger.warn("Failed to fetch display profile '${redacted(publicKey)}'", it, context = TAG) } // endregion @@ -642,7 +815,7 @@ class PubkyRepo @Inject constructor( status = null, ) _publicKey.update { publicKey } - _profile.update { createdProfile } + setProfile(createdProfile) cacheMetadata(createdProfile) settingsStore.setPubkyProfileSetupPending(false) notifyBackupStateChanged() @@ -710,7 +883,7 @@ class PubkyRepo @Inject constructor( tags = tags, status = _profile.value?.status, ) - _profile.update { profile } + setProfile(profile) cacheMetadata(profile) notifyBackupStateChanged() } @@ -740,6 +913,7 @@ class PubkyRepo @Inject constructor( } Logger.info("Continuing sign out, bitkit profile storage already missing", context = TAG) } + profileWriteGeneration.incrementAndGet() } settingsStore.update { it.paykitDisabled(markPublicCleanupPending = it.hasPaykitState()) } signOut().getOrThrow() @@ -809,6 +983,12 @@ class PubkyRepo @Inject constructor( // region Contact management + /** + * Publishes the saved contact records without waiting for any profile lookup. A record without a profile override + * or a stored Paykit profile shows the profile resolved earlier in this session, else its label, and is then + * refreshed in the background on the bulk read lane, its row updating once its lookup finishes, unless its profile + * was resolved less than [CONTACT_PROFILE_FRESHNESS] ago. + */ suspend fun loadContacts() { val pk = _publicKey.value ?: return loadContactsMutex.lock() @@ -819,6 +999,7 @@ class PubkyRepo @Inject constructor( _isLoadingContacts.update { true } var shouldMarkLoadCompleted = false + var contactsToRefresh = emptyList() try { var reload: Boolean do { @@ -828,26 +1009,9 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { val records = pubkyService.contactRecords() val overrides = pubkyStore.data.first().contactProfileOverrides - - coroutineScope { - records.map { record -> - async { - runSuspendCatching { - contactProfile(record.publicKey, record.label, record.profile, overrides) - }.onFailure { - Logger.warn( - "Failed to load contact '${redacted(record.publicKey)}'", - it, - context = TAG, - ) - }.getOrElse { - PubkyProfile.placeholder(record.publicKey.ensurePubkyPrefix()) - } - } - }.awaitAll().sortedBy { it.name.lowercase() } - } + records.map { savedContact(it, overrides) } } - }.onSuccess { loadedContacts -> + }.onSuccess { savedContacts -> if (_publicKey.value != pk) { Logger.debug("Skipped stale contacts load for '${redacted(pk)}'", context = TAG) return@onSuccess @@ -857,7 +1021,10 @@ class PubkyRepo @Inject constructor( reload = true return@onSuccess } - _contacts.update { loadedContacts } + val now = clock.nowMs() + val loadedContacts = savedContacts.map { it.withSessionProfile(pk, now) } + _contacts.update { loadedContacts.map { it.profile }.sortedBy { it.name.lowercase() } } + contactsToRefresh = loadedContacts.filter { it.needsRefresh } } markContactsLoaded() shouldMarkLoadCompleted = true @@ -871,12 +1038,37 @@ class PubkyRepo @Inject constructor( loadContactsMutex.unlock() if (shouldMarkLoadCompleted && _publicKey.value == pk) markContactsLoadCompleted() } + refreshContactProfiles(pk, contactsToRefresh) + } + + /** + * Resolves a saved contact's profile on the interactive read lane while its row still shows only its label + * because the background refresh of [loadContacts] has not finished looking it up, so a screen showing that + * contact does not wait behind bulk reads and an edit made there keeps the contact's avatar, bio and links. The + * lookup takes the contact over from the refresh, which stops its own lookup and never applies a result for it, + * and a caller arriving meanwhile waits for the same lookup. Only a sign-out or an identity change stops it, not a + * later refresh. It returns at once for any other row; when the lookup fails, the row keeps its label. A profile + * the refresh already found for the contact but has not applied to the list yet is applied at once instead. + */ + suspend fun resolvePendingContactProfile(publicKey: String) { + val owner = _publicKey.value ?: return + val key = publicKey.ensurePubkyPrefix() + val lookup = synchronized(contactsLock) { + contactProfileRefresh?.let { if (key in it.pendingResults) applyRefreshResults(it) } + contactScreenLookups[key]?.takeUnless { it.isCompleted } + ?: contactProfileRefresh?.takeOver(owner, key) { it.profile in _contacts.value }?.let { contact -> + scope.launch(start = CoroutineStart.LAZY) { + refreshContactProfile(owner, contact, PaykitReadLane.Interactive) + }.also { contactScreenLookups[key] = it } + } + } ?: return + lookup.join() } suspend fun fetchContactProfile(publicKey: String): Result { val prefixedKey = runCatching { requireCanonicalAddableContactPublicKey(publicKey) } .getOrElse { return Result.failure(it) } - return resolveContactProfile(prefixedKey) + return resolveContactProfile(prefixedKey, retry = true) .map { it ?: PubkyProfile.placeholder(prefixedKey) } .recoverCatching { if (it is CancellationException) { @@ -897,7 +1089,7 @@ class PubkyRepo @Inject constructor( allowExisting = existingProfile != null, ) val profile = existingProfile?.copy(publicKey = prefixedKey) - ?: resolveContactProfile(prefixedKey).getOrThrow() + ?: resolveContactProfile(prefixedKey, retry = true).getOrThrow() ?: PubkyProfile.placeholder(prefixedKey) pubkyService.saveContact( prefixedKey, @@ -905,6 +1097,7 @@ class PubkyRepo @Inject constructor( relevantReceiverPaths(prefixedKey), restorePrivateConnection = true, ) + _publicKey.value?.let { cacheSessionContactProfiles(it, listOf(profile)) } updateContacts { current -> (current.filter { it.publicKey != prefixedKey } + profile) .sortedBy { it.name.lowercase() } @@ -969,16 +1162,51 @@ class PubkyRepo @Inject constructor( throw it } - suspend fun importContacts(profiles: List): Result = runSuspendCatching { + /** + * Saves [profiles], the follows [prepareImport] resolved, without looking them up again; receiver discovery runs + * during contact refresh. A contact already saved is skipped, and a failed save keeps the others and fails the + * import, so a retry saves only the missing contacts. The import runs in the repository scope, so it finishes even + * when the caller is cancelled, stops saving once the identity changes, and clears the pending import once it + * succeeds. A success bumps [contactImportVersion] and a failure sets [contactImportFailure], so both reach the app + * after the import screens are gone. An import stopped by an identity change, such as a sign-out, reports nothing. + */ + suspend fun importContacts(profiles: List): Result = + scope.async(start = CoroutineStart.UNDISPATCHED) { + val owner = _publicKey.value + activeContactImports.update { it + 1 } + try { + saveImportedContacts(profiles) + .onSuccess { _contactImportVersion.update { it + 1 } } + .onFailure { error -> + if (_publicKey.value != owner) { + Logger.info("Stopped a contact import after the identity changed", context = TAG) + return@onFailure + } + Logger.error("Failed to import contacts", error, context = TAG) + _contactImportFailure.update { error } + } + } finally { + activeContactImports.update { it - 1 } + } + }.await() + + private suspend fun saveImportedContacts(profiles: List): Result = runSuspendCatching { withContext(ioDispatcher) { + val owner = requireNotNull(_publicKey.value) { "Not authenticated" } val imported = mutableListOf() 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 + check(_publicKey.value == owner) { "Pubky identity changed while importing contacts" } runSuspendCatching { // The preview already resolved this profile. Receiver discovery runs during contact refresh. - pubkyService.saveContact(profile.publicKey, profile.name, restorePrivateConnection = true) + pubkyService.saveContact( + profile.publicKey, + profile.name, + restorePrivateConnection = true, + expectedIdentity = owner, + ) imported.add(profile) existing.add(profile.publicKey) }.onFailure { @@ -986,17 +1214,29 @@ class PubkyRepo @Inject constructor( Logger.warn("Failed to import contact '${redacted(profile.publicKey)}'", it, context = TAG) } } - updateContacts { current -> - val currentKeys = current.map { it.publicKey }.toSet() - (current + imported.filter { it.publicKey !in currentKeys }) - .sortedBy { it.name.lowercase() } + synchronized(contactsLock) { + check(_publicKey.value == owner) { "Pubky identity changed while importing contacts" } + cacheSessionContactProfiles(owner, imported) + updateContacts { current -> + 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 } + clearPendingImport() } } + /** + * Looks up the signed-in identity's follows for the import overview, leaving out its own key, resolving each + * follow's profile once and keeping a follow it cannot resolve as a placeholder. Its lookups use the interactive + * read lane, since the user waits on the Pubky Ring choice row until it returns, and each one still running + * [IMPORT_FOLLOW_LOOKUP_TIMEOUT] after it got its read slot is cancelled and treated as failed; waiting for a read + * slot does not count. + */ suspend fun prepareImport(): Result = runSuspendCatching { clearPendingImport() val pk = requireNotNull(_publicKey.value) { "Not authenticated" } @@ -1007,19 +1247,39 @@ class PubkyRepo @Inject constructor( } Logger.debug("Discovered '${contactKeys.size}' contacts for import", context = TAG) - val contacts = coroutineScope { + val resolvedFollows = coroutineScope { contactKeys.map { contactPk -> val prefixedKey = contactPk.ensurePubkyPrefix() async { - runSuspendCatching { - resolveContactProfile(prefixedKey).getOrThrow() ?: PubkyProfile.placeholder(prefixedKey) - }.getOrElse { PubkyProfile.placeholder(prefixedKey) } + prefixedKey to runSuspendCatching { + pubkyService.resolveContactProfile( + publicKey = prefixedKey, + allowPubkyProfileFallback = true, + lane = PaykitReadLane.Interactive, + timeout = IMPORT_FOLLOW_LOOKUP_TIMEOUT, + )?.let(::profileFromResolution) + }.onFailure { + if (it.isPaykitReadTimeout()) { + Logger.warn( + "Timed out resolving follow '${redacted(prefixedKey)}' after " + + "'$IMPORT_FOLLOW_LOOKUP_TIMEOUT'", + context = TAG, + ) + } else { + Logger.warn("Failed to resolve follow '${redacted(prefixedKey)}'", it, context = TAG) + } + }.getOrNull() } - }.awaitAll().sortedBy { it.name.lowercase() } + }.awaitAll() } + val contacts = resolvedFollows.map { (key, profile) -> profile ?: PubkyProfile.placeholder(key) } + .sortedBy { it.name.lowercase() } - val ownProfile = resolveContactProfile(pk).getOrNull() + val ownProfile = _profile.value?.takeIf { PubkyPublicKeyFormat.matches(it.publicKey, pk) } + ?: resolveContactProfile(pk, retry = true).getOrNull() + check(_publicKey.value == pk) { "Pubky identity changed while preparing import" } + cacheSessionContactProfiles(pk, resolvedFollows.mapNotNull { it.second }) _pendingImportProfile.update { ownProfile } _pendingImportContacts.update { contacts } } @@ -1030,6 +1290,19 @@ class PubkyRepo @Inject constructor( _pendingImportContacts.update { emptyList() } } + /** + * Drops the pending import when the user leaves the import screens, unless an import is running: that import + * clears it once it succeeds and keeps it when it fails, so the follows it could not save stay pending. + */ + suspend fun discardPendingImport() = withContext(ioDispatcher) { + if (activeContactImports.value > 0) return@withContext + clearPendingImport() + } + + fun clearContactImportFailure() { + _contactImportFailure.update { null } + } + // endregion // region Auth approval @@ -1298,50 +1571,148 @@ class PubkyRepo @Inject constructor( imageLoader.memoryCache?.let { cache -> cache.keys.filter { it.key.startsWith(PUBKY_SCHEME) }.forEach { cache.remove(it) } } - val imageUris = buildList { - _profile.value?.imageUrl?.let { add(it) } - addAll(_contacts.value.mapNotNull { it.imageUrl }) + clearPubkyImageDiskCache() + } + + private fun clearPubkyImageDiskCache() { + imageCacheEpoch.advance() + runCatching { imageLoader.diskCache?.clear() } + .onFailure { Logger.warn("Failed to clear pubky image disk cache", it, context = TAG) } + } + + private fun savedContact(record: ContactRecord, overrides: Map): SavedContact { + val prefixedKey = record.publicKey.ensurePubkyPrefix() + overrides[prefixedKey]?.let { + return SavedContact(it.toPubkyProfile(prefixedKey), record.label, needsRefresh = false) + } + record.profile?.let { + val profile = PubkyProfile.fromPaykitProfile(prefixedKey, it).withNameFallback(record.label) + return SavedContact(profile, record.label, needsRefresh = false) } - imageLoader.diskCache?.let { cache -> - imageUris.forEach { cache.remove(it) } + val profile = PubkyProfile.forDisplay(publicKey = prefixedKey, name = record.label, imageUrl = null) + return SavedContact(profile, record.label, needsRefresh = true, showsLabelOnly = true) + } + + private fun SavedContact.withSessionProfile(owner: String, now: Long): SavedContact { + if (!needsRefresh) return this + val cached = sessionContactProfile(owner, profile.publicKey) ?: return this + return copy( + profile = cached.profile.withNameFallback(label), + needsRefresh = !cached.isFresh(now), + showsLabelOnly = false, + ) + } + + private fun refreshContactProfiles(owner: String, contacts: List) { + if (contacts.isEmpty()) return + val refresh = ContactProfileRefresh( + owner = owner, + contacts = contacts, + scope = scope, + lookUp = { refresh, contact -> refreshContactProfile(owner, contact, PaykitReadLane.Bulk, refresh) }, + onFinished = { synchronized(contactsLock) { applyRefreshResults(it) } }, + ) + val replaced = synchronized(contactsLock) { + val active = contactProfileRefresh + if (_publicKey.value != owner || active?.covers(refresh) == true) { + refresh.job.cancel() + return + } + contactProfileRefresh = refresh + active } + replaced?.job?.cancel() + refresh.start() } - private suspend fun contactProfile( - publicKey: String, - label: String?, - paykitProfile: PaykitProfile?, - overrides: Map, - ): PubkyProfile { - val prefixedKey = publicKey.ensurePubkyPrefix() - overrides[prefixedKey]?.let { - return it.toPubkyProfile(prefixedKey) + private suspend fun refreshContactProfile( + owner: String, + contact: SavedContact, + lane: PaykitReadLane, + refresh: ContactProfileRefresh? = null, + ) { + val lookup = currentCoroutineContext().job + val publicKey = contact.profile.publicKey + val resolved = resolveContactProfile(publicKey, retry = false, lane = lane) + .onFailure { Logger.warn("Failed to resolve contact '${redacted(publicKey)}'", it, context = TAG) } + .getOrNull() ?: return + val refreshed = RefreshedContact(contact, resolved.withNameFallback(contact.label)) + synchronized(contactsLock) { + if (_publicKey.value != owner || !lookup.isActive) return + cacheSessionContactProfiles(owner, listOf(resolved)) + when (refresh) { + null -> applyRefreshedContacts(listOf(refreshed)) + else -> queueRefreshedContact(refresh, refreshed) + } } - paykitProfile?.let { - return PubkyProfile.fromPaykitProfile(prefixedKey, it).withNameFallback(label) + } + + private fun queueRefreshedContact(refresh: ContactProfileRefresh, refreshed: RefreshedContact) { + refresh.pendingResults[refreshed.contact.profile.publicKey] = refreshed + if (refresh.scheduledApply != null) return + refresh.scheduledApply = scope.launch(refresh.job) { + delay(CONTACT_REFRESH_BATCH_WINDOW) + synchronized(contactsLock) { applyRefreshResults(refresh) } } - resolveContactProfile(prefixedKey).getOrNull()?.let { - return it.withNameFallback(label) + } + + private fun applyRefreshResults(refresh: ContactProfileRefresh) { + refresh.scheduledApply?.cancel() + refresh.scheduledApply = null + val results = refresh.pendingResults.values.toList() + refresh.pendingResults.clear() + if (results.isEmpty() || refresh.job.isCancelled || _publicKey.value != refresh.owner) return + applyRefreshedContacts(results) + } + + private fun applyRefreshedContacts(results: List) { + val replacements = results.associate { it.contact.profile to it.profile } + if (_contacts.value.none { it in replacements }) return + _contacts.update { current -> current.map { replacements[it] ?: it }.sortedBy { it.name.lowercase() } } + } + + private fun sessionContactProfile(owner: String, publicKey: String): SessionContactProfile? = + synchronized(contactsLock) { + sessionContactProfiles[publicKey].takeIf { sessionContactProfilesOwner == owner } + } + + private fun cacheSessionContactProfiles(owner: String, profiles: Collection) { + synchronized(contactsLock) { + if (_publicKey.value != owner) return + if (sessionContactProfilesOwner != owner) { + sessionContactProfiles.clear() + sessionContactProfilesOwner = owner + } + val now = clock.nowMs() + profiles.filter { it != PubkyProfile.placeholder(it.publicKey) } + .forEach { sessionContactProfiles[it.publicKey] = SessionContactProfile(it, now) } + } + } + + private fun clearSessionContactProfiles() { + synchronized(contactsLock) { + contactProfileRefresh?.job?.cancel() + contactProfileRefresh?.pendingResults?.clear() + contactProfileRefresh = null + contactScreenLookups.values.forEach { it.cancel() } + contactScreenLookups.clear() + sessionContactProfiles.clear() + sessionContactProfilesOwner = null } - return PubkyProfile.forDisplay( - publicKey = prefixedKey, - name = label, - imageUrl = null, - ) } - private suspend fun resolveContactProfile(publicKey: String): Result = runSuspendCatching { + private suspend fun resolveContactProfile( + publicKey: String, + retry: Boolean, + lane: PaykitReadLane = PaykitReadLane.Interactive, + ): Result = runSuspendCatching { withContext(ioDispatcher) { val prefixedKey = publicKey.ensurePubkyPrefix() + if (!retry) return@withContext resolveProfileOnce(prefixedKey, lane) var lastError: Throwable? = null repeat(2) { attempt -> - val result = runSuspendCatching { - pubkyService.resolveContactProfile( - publicKey = prefixedKey, - allowPubkyProfileFallback = true, - )?.let(::profileFromResolution) - } + val result = runSuspendCatching { resolveProfileOnce(prefixedKey, lane) } if (result.isSuccess && (result.getOrNull() != null || attempt == 1)) { return@withContext result.getOrNull() } @@ -1364,6 +1735,13 @@ class PubkyRepo @Inject constructor( } } + private suspend fun resolveProfileOnce(prefixedKey: String, lane: PaykitReadLane): PubkyProfile? = + pubkyService.resolveContactProfile( + publicKey = prefixedKey, + allowPubkyProfileFallback = true, + lane = lane, + )?.let(::profileFromResolution) + private fun profileFromResolution(resolution: ContactProfileResolution): PubkyProfile { val prefixedKey = resolution.publicKey.ensurePubkyPrefix() resolution.paykitProfile?.let { @@ -1411,10 +1789,12 @@ class PubkyRepo @Inject constructor( notifyBackupStateChanged() } - private suspend fun cacheMetadata(profile: PubkyProfile) { + private suspend fun cacheMetadata(profile: PubkyProfile, isCurrent: () -> Boolean = { true }) { pubkyStore.update { + if (!isCurrent()) return@update it it.copy( ownerPublicKey = profile.publicKey, + cachedProfileOwner = profile.publicKey, cachedName = profile.name, cachedImageUri = profile.imageUrl, ) @@ -1475,17 +1855,24 @@ class PubkyRepo @Inject constructor( _backupStateVersion.update { it + 1 } } + private fun setProfile(profile: PubkyProfile?) { + profileWriteGeneration.incrementAndGet() + _profile.update { profile } + } + private suspend fun clearAuthenticatedState( clearCachedProfile: Boolean = true, clearRestorationFailure: Boolean = true, ) = withContext(ioDispatcher) { if (clearCachedProfile) { evictPubkyImages() + profileWriteGeneration.incrementAndGet() runSuspendCatching { pubkyStore.reset() } } _publicKey.update { null } - _profile.update { null } + setProfile(null) updateContacts { emptyList() } + clearSessionContactProfiles() _contactsLoadVersion.update { 0L } _contactsLoadCompletionVersion.update { 0L } clearPendingImport() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt index 03115dd4b9..1d6e162405 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkOperationLock.kt @@ -16,27 +16,26 @@ internal class PaykitSdkOperationLock { private var isWiping = false suspend fun withLock(operation: suspend () -> T): T { - val wipeContext = currentCoroutineContext()[WipeContext] - if (synchronized(stateLock) { - isWiping && wipeContext?.owner === this && wipeContext.generation == generation - } - ) { - return operation() - } - val admittedGeneration = synchronized(stateLock) { - checkAvailable() - generation - } + if (ownsWipe()) return operation() + val admittedGeneration = admit() return mutex.withLock { currentCoroutineContext().ensureActive() - synchronized(stateLock) { - checkAvailable() - if (admittedGeneration != generation) throw wipeError() - } + checkAdmitted(admittedGeneration) operation() } } + /** + * Runs [operation] without the lock but under the wallet wipe admission of [withLock]: it is rejected while a wipe + * is in progress. A wipe cannot drain work that does not hold the lock, so a result that a wipe overtakes is + * discarded and the caller gets the wipe error instead. + */ + suspend fun withoutLock(operation: suspend () -> T): T { + if (ownsWipe()) return operation() + val admittedGeneration = admit() + return operation().also { checkAdmitted(admittedGeneration) } + } + suspend fun withWalletWipe(operation: suspend () -> T): T { synchronized(stateLock) { checkAvailable() @@ -52,6 +51,23 @@ internal class PaykitSdkOperationLock { } } + private suspend fun ownsWipe(): Boolean { + val wipeContext = currentCoroutineContext()[WipeContext] + return synchronized(stateLock) { + isWiping && wipeContext?.owner === this && wipeContext.generation == generation + } + } + + private fun admit(): Long = synchronized(stateLock) { + checkAvailable() + generation + } + + private fun checkAdmitted(admittedGeneration: Long) = synchronized(stateLock) { + checkAvailable() + if (admittedGeneration != generation) throw wipeError() + } + private fun checkAvailable() { if (isWiping) throw wipeError() } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 59bada6697..2bbcce16eb 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -72,6 +72,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -80,9 +81,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update +import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Network @@ -113,6 +117,7 @@ import javax.crypto.spec.SecretKeySpec import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -162,6 +167,8 @@ data class PaykitPrivateReceiverPathSelection( val error: Throwable?, ) +class PubkyFileNotFoundError : AppError("Pubky file not found") + internal object PaykitReceiverPaths { const val WALLET = "bitkit/wallet" const val SERVER = "bitkit/server" @@ -171,6 +178,66 @@ internal object PaykitReceiverPaths { val supported = ordered.toSet() } +/** Which public read slots a public Pubky read may use. */ +enum class PaykitReadLane { + /** + * A read for what the user is looking at or waiting on; it takes only a shared read slot, and a freed read slot + * goes to it before any waiting bulk read. + */ + Interactive, + + /** A background read over many keys; it also takes a bulk slot, so bulk reads never fill every read slot. */ + Bulk, +} + +/** A public Pubky read that ran out of the timeout its caller gave it once it held a read slot. */ +class PaykitReadTimeoutError(timeout: Duration) : AppError("Public Pubky read timed out after $timeout") + +private class TimedRead(val value: T) + +private class PaykitPublicReadSlots(slots: Int) { + private val lock = Any() + private var free = slots + private val interactiveWaiters = ArrayDeque>() + private val bulkWaiters = ArrayDeque>() + + suspend fun withSlot(lane: PaykitReadLane, block: suspend () -> T): T { + acquire(lane) + return try { + block() + } finally { + release() + } + } + + private suspend fun acquire(lane: PaykitReadLane) { + val waiters = when (lane) { + PaykitReadLane.Interactive -> interactiveWaiters + PaykitReadLane.Bulk -> bulkWaiters + } + val slot = synchronized(lock) { + if (free > 0) { + free-- + return + } + CompletableDeferred().also(waiters::addLast) + } + try { + slot.await() + } catch (error: CancellationException) { + if (synchronized(lock) { !waiters.remove(slot) }) release() + throw error + } + } + + private fun release() { + val next = synchronized(lock) { + (interactiveWaiters.removeFirstOrNull() ?: bulkWaiters.removeFirstOrNull()).also { if (it == null) free++ } + } + next?.complete(Unit) + } +} + @Singleton @Suppress("TooManyFunctions", "LargeClass") class PaykitSdkService @Inject constructor( @@ -192,15 +259,22 @@ class PaykitSdkService @Inject constructor( } private val cachedBootstrap by lazy { bootstrapFactory() } private val identityRepublishMutex = Mutex() + + @Volatile + private var identityRepublishJob: Job? = null private var republishPublicKey: String? = null private var nextIdentityRepublishAt = 0L private var lastIdentityRepublishAt = 0L private val handleMutex = Mutex() private val operationLock = PaykitSdkOperationLock() + private val publicReadSlots = PaykitPublicReadSlots(PUBLIC_READ_PERMITS) + private val bulkReadPermits = Semaphore(BULK_READ_PERMITS) private val setupMutex = Mutex() private var isSetup = CompletableDeferred() private var setupFailed = false private var platformInitializer: () -> Unit = { PaykitAndroid.initializeOrThrow(context) } + + @Volatile private var sdk: PaykitSdk? = null private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() @@ -279,9 +353,19 @@ class PaykitSdkService @Inject constructor( } suspend fun republishIdentityIfNeeded(publicKey: String? = null, now: Long = nowMillis()) { + val publication = launchIdentityRepublish(publicKey, now) + withTimeoutOrNull(IDENTITY_REPUBLISH_WAIT_TIMEOUT) { + publication.join() + identityRepublishJob?.join() + true + } ?: Logger.debug("Continuing while Pubky identity publication is pending", context = TAG) + } + + private suspend fun launchIdentityRepublish(publicKey: String?, now: Long = nowMillis()): Job { currentCoroutineContext().ensureActive() - val publication = launch { + return launch { if (!identityRepublishMutex.tryLock()) return@launch + identityRepublishJob = currentCoroutineContext().job try { withTimeoutOrNull(IDENTITY_REPUBLISH_TIMEOUT) { runSuspendCatching { @@ -311,8 +395,6 @@ class PaykitSdkService @Inject constructor( identityRepublishMutex.unlock() } } - withTimeoutOrNull(IDENTITY_REPUBLISH_WAIT_TIMEOUT) { publication.join() } - ?: Logger.debug("Continuing while Pubky identity publication is pending", context = TAG) } /** Rebroadcasts the identity record when one exists. Returns false only when the network reports none. */ @@ -475,12 +557,8 @@ class PaykitSdkService @Inject constructor( } } - suspend fun fetchFile(uri: String, maxBytes: ULong): ByteArray { - isSetup.await() - return operationLock.withLock { - handle().fetchPubkyFileBounded(uri, maxBytes) ?: throw AppError("Pubky file not found") - } - } + suspend fun fetchFile(uri: String, maxBytes: ULong): ByteArray = + publicRead { it.fetchPubkyFileBounded(uri, maxBytes) } ?: throw PubkyFileNotFoundError() suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { isSetup.await() @@ -515,19 +593,11 @@ class PaykitSdkService @Inject constructor( } } - suspend fun fetchPubkyProfile(publicKey: String): PubkyProfile? { - isSetup.await() - return operationLock.withLock { - handle().fetchPubkyProfile(publicKey)?.profile - } - } + suspend fun fetchPubkyProfile(publicKey: String): PubkyProfile? = + publicRead { it.fetchPubkyProfile(publicKey) }?.profile - suspend fun fetchPubkyFollows(publicKey: String): List { - isSetup.await() - return operationLock.withLock { - handle().fetchPubkyFollows(publicKey) - } - } + suspend fun fetchPubkyFollows(publicKey: String): List = + publicRead { it.fetchPubkyFollows(publicKey) } suspend fun contactRecords(): List { isSetup.await() @@ -548,10 +618,16 @@ class PaykitSdkService @Inject constructor( label: String?, receiverPaths: List? = null, restorePrivateConnection: Boolean = false, + expectedIdentity: String? = null, ): ContactRecord { isSetup.await() return operationLock.withLock { withStateRevisionTracking { handle -> + if (expectedIdentity != null) { + check(PubkyPublicKeyFormat.matches(handle.identityStatus()?.publicKey, expectedIdentity)) { + "Paykit identity changed before saving the contact" + } + } val existing = handle.contactRecord(publicKey) check(restorePrivateConnection || existing != null) { "Contact no longer exists" } val existingPaths = existing?.receiverPaths.orEmpty() @@ -608,62 +684,57 @@ class PaykitSdkService @Inject constructor( suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, - ): ContactProfileResolution? { - isSetup.await() - return operationLock.withLock { - handle().resolveContactProfile(publicKey, PaykitReceiverPaths.WALLET, allowPubkyProfileFallback) - } + lane: PaykitReadLane = PaykitReadLane.Interactive, + timeout: Duration? = null, + ): ContactProfileResolution? = publicRead(lane, timeout) { + it.resolveContactProfile(publicKey, PaykitReceiverPaths.WALLET, allowPubkyProfileFallback) } - suspend fun discoverRelevantReceiverPaths(publicKey: String): List { - isSetup.await() - return operationLock.withLock { - val handle = handle() - val discovered = handle.paykitReceiverPaths(publicKey) - .filter { it in PaykitReceiverPaths.supported } - .filter { - it == PaykitReceiverPaths.WALLET || - handle.paykitReceiverMarker(publicKey, it)?.requiresPrivateLink() == true - } - mergedReceiverPaths(discovered) - } + suspend fun discoverRelevantReceiverPaths( + publicKey: String, + lane: PaykitReadLane = PaykitReadLane.Interactive, + ): List = publicRead(lane) { handle -> + val discovered = handle.paykitReceiverPaths(publicKey) + .filter { it in PaykitReceiverPaths.supported } + .filter { + it == PaykitReceiverPaths.WALLET || + handle.paykitReceiverMarker(publicKey, it)?.requiresPrivateLink() == true + } + mergedReceiverPaths(discovered) } suspend fun privateReceiverPathSelection( publicKey: String, savedReceiverPaths: List, - ): PaykitPrivateReceiverPathSelection { - isSetup.await() - return operationLock.withLock { - val handle = handle() - val linkable = mutableListOf() - val publishable = mutableListOf() - val cleanupProtected = mutableListOf() - var firstError: Throwable? = null - - mergedReceiverPaths(savedReceiverPaths).forEach { receiverPath -> - runSuspendCatching { handle.paykitReceiverMarker(publicKey, receiverPath) } - .onSuccess { marker -> - if (marker?.requiresPrivateLink() == true) { - linkable += receiverPath - } - if (marker.canReceivePrivatePaymentDetails()) { - publishable += receiverPath - } + lane: PaykitReadLane, + ): PaykitPrivateReceiverPathSelection = publicRead(lane) { handle -> + val linkable = mutableListOf() + val publishable = mutableListOf() + val cleanupProtected = mutableListOf() + var firstError: Throwable? = null + + mergedReceiverPaths(savedReceiverPaths).forEach { receiverPath -> + runSuspendCatching { handle.paykitReceiverMarker(publicKey, receiverPath) } + .onSuccess { marker -> + if (marker?.requiresPrivateLink() == true) { + linkable += receiverPath } - .onFailure { - cleanupProtected += receiverPath - firstError = firstError ?: it + if (marker.canReceivePrivatePaymentDetails()) { + publishable += receiverPath } - } - - PaykitPrivateReceiverPathSelection( - linkableReceiverPaths = linkable, - publishableReceiverPaths = publishable, - cleanupProtectedReceiverPaths = cleanupProtected, - error = firstError, - ) + } + .onFailure { + cleanupProtected += receiverPath + firstError = firstError ?: it + } } + + PaykitPrivateReceiverPathSelection( + linkableReceiverPaths = linkable, + publishableReceiverPaths = publishable, + cleanupProtectedReceiverPaths = cleanupProtected, + error = firstError, + ) } suspend fun syncLocalReceiverMarker( @@ -783,15 +854,12 @@ class PaykitSdkService @Inject constructor( } } - suspend fun paymentRequestReceiverPaths(publicKey: String): List { - isSetup.await() - return operationLock.withLock { - val handle = handle() + suspend fun paymentRequestReceiverPaths(publicKey: String, lane: PaykitReadLane): List = + publicRead(lane) { handle -> handle.paykitReceiverPaths(publicKey) .filter { it in PaykitReceiverPaths.supported } .filter { handle.paykitReceiverMarker(publicKey, it)?.capabilities?.paymentRequests == true } } - } suspend fun proposePaymentRequest( counterparty: String, @@ -1078,7 +1146,7 @@ class PaykitSdkService @Inject constructor( val handle = handle() handle.initialize() publishReceiverMarkerIfLiveSessionAvailable(handle) - republishIdentityIfNeeded(publicKey = result.publicKey) + launchIdentityRepublish(publicKey = result.publicKey) } private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) { @@ -1157,6 +1225,41 @@ class PaykitSdkService @Inject constructor( sdkFactory().also { sdk = it } } + /** + * Runs [block] on the SDK instance without [operationLock]. [block] may only call unauthenticated public + * Pubky reads, never session, secret, state-blob or publishing APIs. Without an instance it builds one under + * [operationLock], because building one outside it would race [resetRuntime] and a wallet wipe, but reads + * under its read slot. Like a locked call, a read that starts during a wallet wipe is rejected, and one that the + * wipe overtakes fails instead of returning its result across the wipe. A [PaykitReadLane.Bulk] read takes a + * bulk permit before its read slot, always in that order, so bulk reads hold at most [BULK_READ_PERMITS] read + * slots and the rest stay free for interactive reads. A freed read slot goes to the oldest waiting interactive + * read before any waiting bulk read, also one already queued for a slot, and reads of one lane start in the order + * they asked for a slot. A [timeout] limits only the time [block] runs once the read holds its read slot, so + * waiting for a slot never counts; a read that runs out is cancelled, gives back its slots and fails with + * [PaykitReadTimeoutError]. + */ + private suspend fun publicRead( + lane: PaykitReadLane = PaykitReadLane.Interactive, + timeout: Duration? = null, + block: suspend (PaykitSdk) -> T, + ): T { + isSetup.await() + return operationLock.withoutLock { + val existing = sdk ?: operationLock.withLock { handle() } + val read: suspend () -> T = { withReadTimeout(timeout) { block(existing) } } + when (lane) { + PaykitReadLane.Interactive -> publicReadSlots.withSlot(lane, read) + PaykitReadLane.Bulk -> bulkReadPermits.withPermit { publicReadSlots.withSlot(lane, read) } + } + } + } + + private suspend fun withReadTimeout(timeout: Duration?, block: suspend () -> T): T { + if (timeout == null) return block() + val read = withTimeoutOrNull(timeout) { TimedRead(block()) } ?: throw PaykitReadTimeoutError(timeout) + return read.value + } + private fun bootstrap() = cachedBootstrap private fun approvalBootstrap(authUrl: String, approvedClientId: String): PubkySessionBootstrap { @@ -1198,6 +1301,12 @@ class PaykitSdkService @Inject constructor( /** Maximum time identity maintenance may delay its caller. */ private val IDENTITY_REPUBLISH_WAIT_TIMEOUT = 5.seconds + /** Maximum concurrent public Pubky reads that run outside the operation lock. */ + private const val PUBLIC_READ_PERMITS = 6 + + /** Maximum concurrent bulk public reads, which leaves the other read permits to interactive reads. */ + private const val BULK_READ_PERMITS = 4 + fun localSecretKey(secretKeyHex: String): PubkyLocalSecretKey = PubkyLocalSecretKey(secretKeyHex.fromHex()) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 8cb0667ccc..6c12fc7c1f 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -7,6 +7,8 @@ import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PaykitPublicKeys import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkySessionBootstrapResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.withTimeoutOrNull import to.bitkit.async.ServiceQueue import to.bitkit.ext.runSuspendCatching @@ -170,7 +172,7 @@ class PubkyService @Inject constructor( // region File operations - suspend fun fetchFile(uri: String, maxBytes: ULong): ByteArray = ServiceQueue.CORE.background { + suspend fun fetchFile(uri: String, maxBytes: ULong): ByteArray = cancellablePublicRead { paykitSdkService.fetchFile(uri, maxBytes) } @@ -190,7 +192,7 @@ class PubkyService @Inject constructor( paykitSdkService.deletePaykitProfile() } - suspend fun getContacts(publicKey: String): List = ServiceQueue.CORE.background { + suspend fun getContacts(publicKey: String): List = cancellablePublicRead { paykitSdkService.fetchPubkyFollows(publicKey) } @@ -203,8 +205,9 @@ class PubkyService @Inject constructor( label: String?, receiverPaths: List? = null, restorePrivateConnection: Boolean = false, + expectedIdentity: String? = null, ): ContactRecord = ServiceQueue.CORE.background { - paykitSdkService.saveContact(publicKey, label, receiverPaths, restorePrivateConnection) + paykitSdkService.saveContact(publicKey, label, receiverPaths, restorePrivateConnection, expectedIdentity) } suspend fun removeContact(publicKey: String): ContactRecord? = ServiceQueue.CORE.background { @@ -214,15 +217,23 @@ class PubkyService @Inject constructor( suspend fun resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Boolean, - ): ContactProfileResolution? = ServiceQueue.CORE.background { - paykitSdkService.resolveContactProfile(publicKey, allowPubkyProfileFallback) + lane: PaykitReadLane = PaykitReadLane.Interactive, + timeout: Duration? = null, + ): ContactProfileResolution? = cancellablePublicRead { + paykitSdkService.resolveContactProfile(publicKey, allowPubkyProfileFallback, lane, timeout) } - suspend fun discoverRelevantReceiverPaths(publicKey: String): List = ServiceQueue.CORE.background { - paykitSdkService.discoverRelevantReceiverPaths(publicKey) + suspend fun discoverRelevantReceiverPaths( + publicKey: String, + lane: PaykitReadLane = PaykitReadLane.Interactive, + ): List = cancellablePublicRead { + paykitSdkService.discoverRelevantReceiverPaths(publicKey, lane) } // endregion + + private suspend fun cancellablePublicRead(block: suspend CoroutineScope.() -> T): T = + ServiceQueue.CORE.background(ServiceQueue.CORE.queueContext.minusKey(Job), block) } class PubkyRingAuthTimeoutError : AppError("Ring authorization timed out") diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d0967bc607..3486df436a 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -757,7 +757,7 @@ fun ContentView( hasSeenShopIntro = hasSeenShopIntro, onBeforeNavigate = { destination -> if (shouldDiscardPendingImport(navController.currentDestination, destination)) { - appViewModel.clearPendingPubkyImport() + appViewModel.discardPendingPubkyImport() } }, hasSeenProfileIntro = hasSeenProfileIntro, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 86898463e9..3c470571df 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -79,6 +79,7 @@ class ContactDetailViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() + private var contactLoad: Job? = null private var payJob: Job? = null private var paymentScanJob: Job? = null private var paymentRequestTargetRefresh: Deferred? = null @@ -92,7 +93,7 @@ class ContactDetailViewModel @Inject constructor( } fun loadContact() { - viewModelScope.launch { + contactLoad = viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val cached = pubkyRepo.contacts.value.find { it.publicKey == publicKey } if (cached != null) { @@ -104,6 +105,7 @@ class ContactDetailViewModel @Inject constructor( isLoading = false, ) } + pubkyRepo.resolvePendingContactProfile(publicKey) return@launch } pubkyRepo.fetchContactProfile(publicKey) @@ -347,8 +349,12 @@ class ContactDetailViewModel @Inject constructor( ) { viewModelScope.launch { tagPersistenceMutex.withLock { + contactLoad?.join() + pubkyRepo.resolvePendingContactProfile(publicKey) val state = _uiState.value - val profile = state.profile ?: return@withLock + val profile = pubkyRepo.contacts.value.find { it.publicKey == publicKey } + ?: state.profile + ?: return@withLock val tags = transform(state.tags) if (tags == state.tags) { onSuccess() diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt index 7b6c941a8f..3b368ae1e2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -78,7 +79,6 @@ fun ContactImportOverviewScreen( LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - ContactImportOverviewEffect.ImportComplete -> onImportComplete() ContactImportOverviewEffect.NavigateBack -> onBackClick() ContactImportOverviewEffect.NavigateToSelect -> onNavigateToSelect() } @@ -157,13 +157,17 @@ private fun Content( text = stringResource(R.string.contacts__import_select), onClick = onClickSelect, enabled = !uiState.isImporting, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag("ContactImportOverviewSelect") ) PrimaryButton( text = stringResource(R.string.contacts__import_all), onClick = onClickImportAll, isLoading = uiState.isImporting, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag("ContactImportOverviewImportAll") ) } VerticalSpacer(16.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModel.kt index 75a012497c..9df883ad3a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModel.kt @@ -1,11 +1,9 @@ package to.bitkit.ui.screens.contacts -import android.content.Context import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -14,32 +12,26 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import to.bitkit.R import to.bitkit.models.PubkyProfile -import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo -import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel class ContactImportOverviewViewModel @Inject constructor( - @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, ) : ViewModel() { - companion object { - private const val TAG = "ContactImportOverviewVM" - } - private val _uiState = MutableStateFlow(ContactImportOverviewUiState()) val uiState: StateFlow = _uiState.asStateFlow() private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() + private var hasLeft = false + init { viewModelScope.launch { val profile = pubkyRepo.pendingImportProfile.value @@ -55,6 +47,14 @@ class ContactImportOverviewViewModel @Inject constructor( ) } } + viewModelScope.launch { + pubkyRepo.isImportingContacts.collect { isImporting -> + _uiState.update { it.copy(isImporting = isImporting) } + } + } + viewModelScope.launch { + pubkyRepo.contactImportVersion.drop(1).collect { completeImport() } + } } fun importAll() { @@ -65,19 +65,7 @@ class ContactImportOverviewViewModel @Inject constructor( _uiState.update { it.copy(isImporting = true) } viewModelScope.launch { 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, - ) - } + pubkyRepo.importContacts(contacts).onSuccess { completeImport() } } finally { _uiState.update { it.copy(isImporting = false) } } @@ -85,17 +73,24 @@ class ContactImportOverviewViewModel @Inject constructor( } fun navigateToSelect() { + if (_uiState.value.isImporting) return viewModelScope.launch { _effects.emit(ContactImportOverviewEffect.NavigateToSelect) } } fun onBackClick() { + hasLeft = true viewModelScope.launch { - pubkyRepo.clearPendingImport() + pubkyRepo.discardPendingImport() _effects.emit(ContactImportOverviewEffect.NavigateBack) } } + + private fun completeImport() { + if (hasLeft) return + _uiState.update { it.copy(shouldRedirectToPayContacts = true) } + } } @Stable @@ -107,7 +102,6 @@ data class ContactImportOverviewUiState( ) sealed interface ContactImportOverviewEffect { - data object ImportComplete : ContactImportOverviewEffect data object NavigateToSelect : ContactImportOverviewEffect data object NavigateBack : ContactImportOverviewEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt index 5d047216c5..2bc54b43e6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -154,6 +155,7 @@ private fun Content( text = stringResource(R.string.common__continue), onClick = onClickContinue, isLoading = uiState.isImporting, + modifier = Modifier.testTag("ContactImportSelectContinue") ) VerticalSpacer(16.dp) } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModel.kt index 32bba0bda0..c3f4b201b4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModel.kt @@ -1,11 +1,9 @@ package to.bitkit.ui.screens.contacts -import android.content.Context import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -16,24 +14,15 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import to.bitkit.R import to.bitkit.models.PubkyProfile -import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo -import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel class ContactImportSelectViewModel @Inject constructor( - @ApplicationContext private val context: Context, private val pubkyRepo: PubkyRepo, ) : ViewModel() { - companion object { - private const val TAG = "ContactImportSelectVM" - } - private val _uiState = MutableStateFlow(ContactImportSelectUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -56,6 +45,11 @@ class ContactImportSelectViewModel @Inject constructor( ) } } + viewModelScope.launch { + pubkyRepo.isImportingContacts.collect { isImporting -> + _uiState.update { it.copy(isImporting = isImporting) } + } + } } fun toggleContact(publicKey: String) { @@ -98,17 +92,8 @@ class ContactImportSelectViewModel @Inject constructor( 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) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt index cfd1cb7439..d6daf9db82 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactsViewModel.kt @@ -36,9 +36,14 @@ class ContactsViewModel @Inject constructor( profile ?: publicKey?.let { PubkyProfile.forDisplay(it, displayName, displayImageUri) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + private val isLoadingFirstContacts = combine( + pubkyRepo.isLoadingContacts, + pubkyRepo.contactsLoadVersion, + ) { isLoading, loadVersion -> isLoading && loadVersion == 0L } + val uiState: StateFlow = combine( pubkyRepo.contacts, - pubkyRepo.isLoadingContacts, + isLoadingFirstContacts, myProfile, _searchText, ) { contacts, isLoading, myProfileValue, search -> diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt index 3eb8ac3d54..63b71068e4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt @@ -16,6 +16,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R @@ -51,6 +54,8 @@ class EditContactViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() + private var hasEdits = false + init { observeContactUpdates() retryLoadContact() @@ -58,12 +63,6 @@ class EditContactViewModel @Inject constructor( fun retryLoadContact() { viewModelScope.launch { - val cachedContact = pubkyRepo.contacts.value.find { it.publicKey == publicKey } - if (cachedContact != null) { - applyContact(cachedContact) - return@launch - } - _uiState.update { it.copy( isLoading = true, @@ -71,11 +70,17 @@ class EditContactViewModel @Inject constructor( ) } + val cachedContact = pubkyRepo.contacts.value.find { it.publicKey == publicKey } + if (cachedContact != null) { + applyResolvedContact(cachedContact) + return@launch + } + pubkyRepo.loadContacts() val refreshedContact = pubkyRepo.contacts.value.find { it.publicKey == publicKey } if (refreshedContact != null) { - applyContact(refreshedContact) + applyResolvedContact(refreshedContact) return@launch } @@ -91,12 +96,19 @@ class EditContactViewModel @Inject constructor( private fun observeContactUpdates() { viewModelScope.launch { - pubkyRepo.contacts.collectLatest { contacts -> - contacts.find { it.publicKey == publicKey }?.let { applyContact(it) } - } + pubkyRepo.contacts + .map { contacts -> contacts.find { it.publicKey == publicKey } } + .filterNotNull() + .distinctUntilChanged() + .collectLatest { if (!_uiState.value.isLoading && !hasEdits) applyContact(it) } } } + private suspend fun applyResolvedContact(contact: PubkyProfile) { + pubkyRepo.resolvePendingContactProfile(publicKey) + applyContact(pubkyRepo.contacts.value.find { it.publicKey == publicKey } ?: contact) + } + private fun applyContact(contact: PubkyProfile) { _uiState.update { it.copy( @@ -114,14 +126,17 @@ class EditContactViewModel @Inject constructor( } fun onNameChange(name: String) { + hasEdits = true _uiState.update { it.copy(name = name) } } fun onBioChange(bio: String) { + hasEdits = true _uiState.update { it.copy(bio = bio) } } fun addLink(label: String, url: String) { + hasEdits = true _uiState.update { it.copy( links = (it.links + ProfileEditLink(label, url)).toImmutableList(), @@ -131,6 +146,7 @@ class EditContactViewModel @Inject constructor( } fun updateLinkUrl(index: Int, url: String) { + hasEdits = true _uiState.update { it.copy( links = it.links.mapIndexed { i, link -> @@ -141,12 +157,14 @@ class EditContactViewModel @Inject constructor( } fun removeLink(index: Int) { + hasEdits = true _uiState.update { it.copy(links = it.links.filterIndexed { i, _ -> i != index }.toImmutableList()) } } fun addTag(tag: String) { + hasEdits = true _uiState.update { it.copy( tags = (it.tags + tag).toImmutableList(), @@ -156,6 +174,7 @@ class EditContactViewModel @Inject constructor( } fun removeTag(index: Int) { + hasEdits = true _uiState.update { it.copy(tags = it.tags.filterIndexed { i, _ -> i != index }.toImmutableList()) } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt index 8814fe01a9..b9c14bc13f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.data.PubkyCachedProfile import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.ui.components.ActionButton @@ -127,6 +128,7 @@ private fun Content( onDismissCopiedPopup: () -> Unit, ) { val currentProfile = uiState.profile + val cachedProfile = uiState.cachedProfile val topBar = @Composable { modifier: Modifier -> AppTopBar( titleText = stringResource(R.string.profile__nav_title), @@ -136,17 +138,8 @@ private fun Content( ) } - if (currentProfile == null) { - ScreenColumn { - topBar(Modifier) - if (uiState.isLoading) { - LoadingState() - } else { - EmptyState(onClickRetry = onClickRetry, onClickSignOut = onClickSignOut) - } - } - } else { - Box(modifier = Modifier.screen()) { + when { + currentProfile != null -> Box(modifier = Modifier.screen()) { ProfileBody( profile = currentProfile, onClickEdit = onClickEdit, @@ -162,6 +155,20 @@ private fun Content( modifier = Modifier.align(Alignment.Center) ) } + + cachedProfile != null -> Box(modifier = Modifier.screen()) { + CachedProfileBody(profile = cachedProfile) + topBar(Modifier.background(TopBarGradient)) + } + + else -> ScreenColumn { + topBar(Modifier) + if (uiState.isLoading) { + LoadingState() + } else { + EmptyState(onClickRetry = onClickRetry, onClickSignOut = onClickSignOut) + } + } } if (uiState.showSignOutDialog) { @@ -317,6 +324,32 @@ private fun ProfileBody( } } +@Composable +private fun CachedProfileBody(profile: PubkyCachedProfile) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .testTag("ProfileCachedHeader") + ) { + TopBarSpacer() + VerticalSpacer(24.dp) + + CenteredProfileHeader( + publicKey = profile.publicKey, + name = profile.name, + bio = "", + imageUrl = profile.imageUri, + nameTestTag = "ProfileCachedName", + ) + + VerticalSpacer(32.dp) + GradientCircularProgressIndicator(modifier = Modifier.size(24.dp)) + } +} + @Composable private fun CopiedPopup( publicKey: String?, @@ -399,6 +432,36 @@ private fun EmptyState( } } +@Preview(showSystemUi = true) +@Composable +private fun PreviewCached() { + AppThemeSurface { + Content( + uiState = ProfileUiState( + cachedProfile = PubkyCachedProfile( + publicKey = "pk8e3qm5...gxag", + name = "Satoshi", + imageUri = null, + ), + isLoading = true, + ), + onBackClick = {}, + onClickEdit = {}, + onClickCopy = {}, + onClickShare = {}, + onClickSignOut = {}, + onDismissSignOutDialog = {}, + onConfirmSignOut = {}, + onClickRetry = {}, + onClickAddTag = {}, + onRemoveTag = {}, + onDismissAddTagSheet = {}, + onSaveTag = {}, + onDismissCopiedPopup = {}, + ) + } +} + @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index 55c32abd4e..6ae1d00fa0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -22,6 +23,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.R +import to.bitkit.data.PubkyCachedProfile import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.setClipboardText import to.bitkit.models.PubkyProfile @@ -66,37 +68,50 @@ class ProfileViewModel @Inject constructor( ProfileControls(showSignOutDialog, isSigningOut, showAddTagSheet, copiedPublicKey) } + init { + if (pubkyRepo.isLoadingProfile.value) loadProfileAfterInFlightLoad() else loadProfile() + } + val uiState: StateFlow = combine( pubkyRepo.profile, pubkyRepo.publicKey, isLoading, + pubkyRepo.cachedProfile, controls, - ) { profile, publicKey, isLoading, controls -> - ProfileUiState( - profile = profile, - publicKey = publicKey, - isLoading = isLoading, - showSignOutDialog = controls.showSignOutDialog, - isSigningOut = controls.isSigningOut, - showAddTagSheet = controls.showAddTagSheet, - copiedPublicKey = controls.copiedPublicKey, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState()) + ) { profile, publicKey, isLoading, cachedProfile, controls -> + profileUiState(profile, publicKey, isLoading, cachedProfile, controls) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + profileUiState( + profile = pubkyRepo.profile.value, + publicKey = pubkyRepo.publicKey.value, + isLoading = pubkyRepo.isLoadingProfile.value || pubkyRepo.isRestoringSession.value || _isRefreshing.value, + cachedProfile = pubkyRepo.cachedProfile.value, + controls = ProfileControls(), + ), + ) private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() - init { - loadProfile() + fun loadProfile() = launchProfileLoad { + val restored = pubkyRepo.restoreSessionIfNeeded() + if (!restored) pubkyRepo.loadProfile() + } + + private fun loadProfileAfterInFlightLoad() = launchProfileLoad { + pubkyRepo.isLoadingProfile.first { !it } + val publicKey = pubkyRepo.publicKey.value ?: return@launchProfileLoad + if (pubkyRepo.profile.value?.publicKey != publicKey) pubkyRepo.loadProfile() } - fun loadProfile() { + private fun launchProfileLoad(load: suspend () -> Unit) { if (profileLoadJob?.isActive == true) return profileLoadJob = viewModelScope.launch { _isRefreshing.update { true } try { - val restored = pubkyRepo.restoreSessionIfNeeded() - if (!restored) pubkyRepo.loadProfile() + load() } finally { _isRefreshing.update { false } } @@ -176,6 +191,23 @@ class ProfileViewModel @Inject constructor( _copiedPublicKey.update { null } } + private fun profileUiState( + profile: PubkyProfile?, + publicKey: String?, + isLoading: Boolean, + cachedProfile: PubkyCachedProfile?, + controls: ProfileControls, + ) = ProfileUiState( + profile = profile, + cachedProfile = cachedProfile?.takeIf { isLoading && it.publicKey == publicKey }, + publicKey = publicKey, + isLoading = isLoading, + showSignOutDialog = controls.showSignOutDialog, + isSigningOut = controls.isSigningOut, + showAddTagSheet = controls.showAddTagSheet, + copiedPublicKey = controls.copiedPublicKey, + ) + private fun updateTags( transform: (List) -> List, onSuccess: () -> Unit = {}, @@ -213,6 +245,7 @@ class ProfileViewModel @Inject constructor( @Stable data class ProfileUiState( val profile: PubkyProfile? = null, + val cachedProfile: PubkyCachedProfile? = null, val publicKey: String? = null, val isLoading: Boolean = true, val showSignOutDialog: Boolean = false, @@ -222,10 +255,10 @@ data class ProfileUiState( ) private data class ProfileControls( - val showSignOutDialog: Boolean, - val isSigningOut: Boolean, - val showAddTagSheet: Boolean, - val copiedPublicKey: String?, + val showSignOutDialog: Boolean = false, + val isSigningOut: Boolean = false, + val showAddTagSheet: Boolean = false, + val copiedPublicKey: String? = null, ) sealed interface ProfileEffect { diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt index a48ea24504..3da6ec9e29 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt @@ -32,6 +32,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -168,8 +170,7 @@ private fun Content( VerticalSpacer(24.dp) when { - uiState.isLoading || uiState.adoptingPubky != null -> - LoadingState(text = stringResource(R.string.profile__choice_loading_profile)) + uiState.isLoading -> LoadingState(text = stringResource(R.string.profile__choice_loading_profile)) uiState.identities.isEmpty() -> OptionCard( iconResId = R.drawable.ic_user_plus, @@ -181,20 +182,31 @@ private fun Content( else -> uiState.identities.forEachIndexed { index, identity -> if (index > 0) VerticalSpacer(8.dp) + val isAdopting = identity.pubky == uiState.adoptingPubky OptionCard( iconResId = R.drawable.ic_lock_key, text = identity.name, onClick = { onIdentityClick(identity.pubky) }, + enabled = uiState.adoptingPubky == null, + isLoading = isAdopting, caption = identity.caption, trailing = { - PubkyContactAvatar( - profile = PubkyProfile.forDisplay( - publicKey = identity.pubky, - name = identity.name, - imageUrl = identity.imageUrl, - ), - size = 32.dp, - ) + if (identity.isLookingUp && !isAdopting) { + LoadingIndicator( + modifier = Modifier + .size(32.dp) + .testTag("PubkyChoiceIdentityLookup") + ) + } else { + PubkyContactAvatar( + profile = PubkyProfile.forDisplay( + publicKey = identity.pubky, + name = identity.name, + imageUrl = identity.imageUrl, + ), + size = 32.dp, + ) + } }, modifier = Modifier.testTag("PubkyChoiceIdentity") ) @@ -211,6 +223,8 @@ private fun OptionCard( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, + isLoading: Boolean = false, caption: String? = null, trailing: (@Composable () -> Unit)? = null, ) { @@ -220,7 +234,7 @@ private fun OptionCard( .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(Colors.Gray6) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(16.dp) ) { Box( @@ -229,12 +243,16 @@ private fun OptionCard( .size(40.dp) .background(Colors.Black, CircleShape) ) { - Icon( - painter = painterResource(iconResId), - contentDescription = null, - tint = Colors.PubkyGreen, - modifier = Modifier.size(20.dp) - ) + if (isLoading) { + LoadingIndicator() + } else { + Icon( + painter = painterResource(iconResId), + contentDescription = null, + tint = Colors.PubkyGreen, + modifier = Modifier.size(20.dp) + ) + } } HorizontalSpacer(16.dp) Column(modifier = Modifier.weight(1f)) { @@ -247,6 +265,17 @@ private fun OptionCard( } } +@Composable +private fun LoadingIndicator(modifier: Modifier = Modifier) { + val description = stringResource(R.string.profile__choice_loading_profile) + Box( + contentAlignment = Alignment.Center, + modifier = modifier.semantics { contentDescription = description } + ) { + GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) + } +} + @Composable private fun LoadingState(text: String) { Row( @@ -262,18 +291,62 @@ private fun LoadingState(text: String) { @Preview(showBackground = true) @Composable private fun PreviewIdentities() { + val pubky = "a967rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roimbr4" AppThemeSurface { Content( uiState = PubkyChoiceUiState( isLoading = false, identities = persistentListOf( RingIdentity( - pubky = "a967rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roimbr4", - caption = "A967...MBR4", - name = "Satoshi Nakamoto", - imageUrl = null, + pubky = pubky, + profile = PubkyProfile.forDisplay(pubky, name = "Satoshi Nakamoto", imageUrl = null), ), + RingIdentity(pubky = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy"), + ), + ), + onBackClick = {}, + onCreateProfile = {}, + onIdentityClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewLookingUp() { + val pubky = "a967rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roimbr4" + AppThemeSurface { + Content( + uiState = PubkyChoiceUiState( + isLoading = false, + identities = persistentListOf( + RingIdentity( + pubky = pubky, + profile = PubkyProfile.forDisplay(pubky, name = "Satoshi Nakamoto", imageUrl = null), + ), + RingIdentity(pubky = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy", isLookingUp = true), + ), + ), + onBackClick = {}, + onCreateProfile = {}, + onIdentityClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewAdopting() { + val pubky = "a967rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roimbr4" + AppThemeSurface { + Content( + uiState = PubkyChoiceUiState( + isLoading = false, + identities = persistentListOf( + RingIdentity(pubky = pubky, isLookingUp = true), + RingIdentity(pubky = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy", isLookingUp = true), ), + adoptingPubky = pubky, ), onBackClick = {}, onCreateProfile = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt index 17eef049ff..dc49739248 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt @@ -1,7 +1,7 @@ package to.bitkit.ui.screens.profile import android.content.Context -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -9,14 +9,17 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo @@ -49,9 +52,10 @@ class PubkyChoiceViewModel @Inject constructor( } fun onIdentityClick(pubky: String) { + if (_uiState.value.adoptingPubky != null) return + _uiState.update { it.copy(adoptingPubky = pubky) } viewModelScope.launch { - _uiState.update { it.copy(adoptingPubky = pubky) } - pubkyRepo.adoptRingIdentity(pubky) + pubkyRepo.adoptRingIdentity(pubky) { rowProfile(pubky) } .onSuccess { hasProfile -> if (hasProfile) { pubkyRepo.prepareImport().onFailure { @@ -93,23 +97,34 @@ class PubkyChoiceViewModel @Inject constructor( Logger.warn("Failed to list ring identities", it, context = TAG) persistentListOf() } - val identities = pubkys.map { pubky -> - val profile = pubkyRepo.fetchRemoteProfile(pubky).getOrNull() - val truncatedKey = PubkyPublicKeyFormat.display(pubky) - RingIdentity( - pubky = pubky, - caption = truncatedKey.uppercase(), - name = profile?.name?.takeIf { it.isNotBlank() } ?: truncatedKey, - imageUrl = profile?.imageUrl, - ) - }.toImmutableList() - + val identities = pubkys.map { RingIdentity(pubky = it, isLookingUp = true) }.toImmutableList() _uiState.update { it.copy(isLoading = false, identities = identities) } + pubkys.forEach { launch { lookUpProfile(it) } } + } + } + + private fun rowProfile(pubky: String): PubkyProfile? = + _uiState.value.identities.firstOrNull { it.pubky == pubky }?.profile + + private suspend fun lookUpProfile(pubky: String) { + var profile: PubkyProfile? = null + try { + profile = pubkyRepo.fetchDisplayProfile(pubky) + .getOrNull() + ?.takeIf { PubkyPublicKeyFormat.matches(it.publicKey, pubky) } + ?.takeIf { currentCoroutineContext().isActive } + } finally { + _uiState.update { state -> + val identities = state.identities.map { + if (it.pubky == pubky) it.copy(profile = profile ?: it.profile, isLookingUp = false) else it + } + state.copy(identities = identities.toImmutableList()) + } } } } -@Immutable +@Stable data class PubkyChoiceUiState( val isLoading: Boolean = true, val identities: ImmutableList = persistentListOf(), @@ -117,13 +132,16 @@ data class PubkyChoiceUiState( val navigateToProfile: Boolean = false, ) -@Immutable +@Stable data class RingIdentity( val pubky: String, - val caption: String, - val name: String, - val imageUrl: String?, -) + val profile: PubkyProfile? = null, + val isLookingUp: Boolean = false, +) { + val caption: String get() = PubkyPublicKeyFormat.display(pubky).uppercase() + val name: String get() = profile?.name?.takeIf { it.isNotBlank() } ?: PubkyPublicKeyFormat.display(pubky) + val imageUrl: String? get() = profile?.imageUrl +} sealed interface PubkyChoiceEffect { data object NavigateToCreateProfile : PubkyChoiceEffect diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9d70a988d1..e0e4d54018 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -559,6 +559,18 @@ class AppViewModel @Inject constructor( } } } + viewModelScope.launch { + pubkyRepo.contactImportFailure.collect { error -> + if (error != null) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = error.message, + ) + pubkyRepo.clearContactImportFailure() + } + } + } observeReceiveSheetInvoice() observeLdkNodeEvents() observeLightningUsableChannels() @@ -2438,6 +2450,7 @@ class AppViewModel @Inject constructor( if (!PubkyAuthRequest.isSignupUrl(data)) { val isInitializationReady = withTimeoutOrNull(PubkyService.AUTHORIZATION_TIMEOUT) { pubkyRepo.awaitInitialization() + if (!isContactLink) pubkyRepo.awaitIdentityReady() awaitContactDataForDeeplink(isContactLink) } ?: false if (!isInitializationReady) { @@ -5761,9 +5774,9 @@ class AppViewModel @Inject constructor( sheet is Sheet.Pin || sheet is Sheet.PubkyAuth - fun clearPendingPubkyImport() { + fun discardPendingPubkyImport() { viewModelScope.launch { - pubkyRepo.clearPendingImport() + pubkyRepo.discardPendingImport() } } @@ -5836,11 +5849,7 @@ class AppViewModel @Inject constructor( if (isSignup && rejectPubkySignupForExistingIdentity()) return if (!isSignup && pubkyRepo.publicKey.value == null) { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.pubky_auth__no_identity), - description = context.getString(R.string.pubky_auth__no_identity_desc), - ) + showPubkyIdentityUnavailableToast() return } @@ -5855,6 +5864,23 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } + private suspend fun showPubkyIdentityUnavailableToast() { + val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() }.getOrDefault(true) + if (hasIdentity) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.pubky_auth__identity_unavailable), + description = context.getString(R.string.pubky_auth__identity_unavailable_desc), + ) + return + } + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.pubky_auth__no_identity), + description = context.getString(R.string.pubky_auth__no_identity_desc), + ) + } + private suspend fun rejectPubkySignupForExistingIdentity(): Boolean { val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() }.getOrElse { ToastEventBus.send( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc65f16c45..2823c6f62f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -716,6 +716,8 @@ Your Pubky Already signed in Homeserver + Couldn\'t Load Your Pubky Profile + Check your connection and try again. Pubky Identity Required Create a Pubky identity in your profile to approve auth requests. Create a new Pubky identity on this homeserver. Only continue if you trust it. diff --git a/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt b/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt index 48baf35932..e19fd2e5b2 100644 --- a/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt +++ b/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt @@ -1,30 +1,72 @@ package to.bitkit.data +import android.app.Application import androidx.test.core.app.ApplicationProvider +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.disk.DiskCache +import coil3.disk.directory +import coil3.fetch.FetchResult +import coil3.fetch.SourceFetchResult +import coil3.request.CachePolicy import coil3.request.Options import coil3.size.Size import coil3.toUri +import com.synonym.paykit.PaykitException +import okio.FileSystem +import okio.Path.Companion.toOkioPath +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.kotlin.description import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import to.bitkit.services.PubkyFileNotFoundError import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.forEachCase +import to.bitkit.utils.AppError +import java.io.IOException import java.util.concurrent.CancellationException +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant @RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) +@Config(application = Application::class, sdk = [34]) class PubkyImageFetcherTest : BaseUnitTest() { + companion object { + private const val IMAGE_URI = "pubky://image" + private const val BLOB_URI = "pubky://blob_uri" + private val IMAGE_BYTES = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) // PNG header + private val BLOB_BYTES = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) // JPEG header + private val DESCRIPTOR_BYTES = """{"src": "$BLOB_URI"}""".toByteArray() + } + + @get:Rule(order = 1) + val tempFolder = TemporaryFolder() private val pubkyService = mock() - private val factory = PubkyImageFetcher.Factory(pubkyService) + private val cacheEpoch = PubkyImageCacheEpoch() + private var nowMillis = 0L + private val clock = object : Clock { + override fun now(): Instant = Instant.fromEpochMilliseconds(nowMillis) + } + private val failures = PubkyImageFailureCache(cacheEpoch, clock) + private val factory = PubkyImageFetcher.Factory(pubkyService, cacheEpoch, clock) private val options = Options(ApplicationProvider.getApplicationContext(), size = Size.ORIGINAL) @Test @@ -41,38 +83,48 @@ class PubkyImageFetcherTest : BaseUnitTest() { assertNull(fetcher) } + @Test + fun `factory should read from the image loader disk cache`() = test { + val diskCache = createDiskCache() + diskCache.put(IMAGE_URI, IMAGE_BYTES) + val imageLoader = mock() + whenever(imageLoader.diskCache).thenReturn(diskCache) + + val result = checkNotNull(factory.create(IMAGE_URI.toUri(), options, imageLoader)).fetch() + + assertEquals(DataSource.DISK, result.dataSource()) + verifyNoInteractions(pubkyService) + } + @Test fun `fetch should return raw data when response is not json`() = test { - val imageBytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) // PNG header - whenever(pubkyService.fetchFile("pubky://image", PUBKY_IMAGE_MAX_BYTES)).thenReturn(imageBytes) - val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService) + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(IMAGE_BYTES) + val fetcher = createFetcher() val result = fetcher.fetch() - assertNotNull(result) - verify(pubkyService).fetchFile("pubky://image", PUBKY_IMAGE_MAX_BYTES) + assertContentEquals(IMAGE_BYTES, result.bytes()) + verify(pubkyService).fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES) } @Test fun `fetch should follow json file descriptor with pubky src`() = test { - val descriptor = """{"src": "pubky://blob_uri"}""".toByteArray() - val blobBytes = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) // JPEG header - whenever(pubkyService.fetchFile("pubky://image", PUBKY_IMAGE_MAX_BYTES)).thenReturn(descriptor) - whenever(pubkyService.fetchFile("pubky://blob_uri", PUBKY_IMAGE_MAX_BYTES)).thenReturn(blobBytes) - val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService) + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(DESCRIPTOR_BYTES) + whenever(pubkyService.fetchFile(BLOB_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(BLOB_BYTES) + val fetcher = createFetcher() - fetcher.fetch() + val result = fetcher.fetch() - verify(pubkyService).fetchFile("pubky://blob_uri", PUBKY_IMAGE_MAX_BYTES) + assertContentEquals(BLOB_BYTES, result.bytes()) + verify(pubkyService).fetchFile(BLOB_URI, PUBKY_IMAGE_MAX_BYTES) } @Test fun `fetch should preserve cancellation when following file descriptor`() = test { - val descriptor = """{"src": "pubky://blob_uri"}""".toByteArray() - whenever(pubkyService.fetchFile("pubky://image", PUBKY_IMAGE_MAX_BYTES)).thenReturn(descriptor) - whenever(pubkyService.fetchFile("pubky://blob_uri", PUBKY_IMAGE_MAX_BYTES)) + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(DESCRIPTOR_BYTES) + whenever(pubkyService.fetchFile(BLOB_URI, PUBKY_IMAGE_MAX_BYTES)) .thenThrow(CancellationException()) - val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService) + val fetcher = createFetcher() assertFailsWith { fetcher.fetch() } } @@ -80,8 +132,8 @@ class PubkyImageFetcherTest : BaseUnitTest() { @Test fun `fetch should not follow json src with non-pubky scheme`() = test { val descriptor = """{"src": "https://example.com/image.png"}""".toByteArray() - whenever(pubkyService.fetchFile("pubky://image", PUBKY_IMAGE_MAX_BYTES)).thenReturn(descriptor) - val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService) + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(descriptor) + val fetcher = createFetcher() fetcher.fetch() @@ -91,11 +143,261 @@ class PubkyImageFetcherTest : BaseUnitTest() { @Test fun `fetch should not follow json without src field`() = test { val json = """{"name": "test"}""".toByteArray() - whenever(pubkyService.fetchFile("pubky://image", PUBKY_IMAGE_MAX_BYTES)).thenReturn(json) - val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService) + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(json) + val fetcher = createFetcher() val result = fetcher.fetch() assertNotNull(result) } + + @Test + fun `fetch should return a disk cache hit without fetching the file`() = test { + val diskCache = createDiskCache() + diskCache.put(IMAGE_URI, IMAGE_BYTES) + + val result = createFetcher(diskCache).fetch() + + assertEquals(DataSource.DISK, result.dataSource()) + assertContentEquals(IMAGE_BYTES, result.bytes()) + verifyNoInteractions(pubkyService) + } + + @Test + fun `fetch should write the network bytes to the disk cache only when the request allows it`() = test { + val httpsDescriptor = """{"src": "https://example.com/image.png"}""".toByteArray() + listOf( + DiskCacheWriteCase("fetched image", stored = mapOf(IMAGE_URI to IMAGE_BYTES)), + DiskCacheWriteCase( + "followed descriptor blob", + image = DESCRIPTOR_BYTES, + blob = BLOB_BYTES, + bytes = BLOB_BYTES, + stored = mapOf(IMAGE_URI to BLOB_BYTES, BLOB_URI to null), + ), + DiskCacheWriteCase( + "descriptor whose blob fetch fails", + image = DESCRIPTOR_BYTES, + bytes = DESCRIPTOR_BYTES, + stored = mapOf(IMAGE_URI to null), + ), + DiskCacheWriteCase( + "json without a pubky src", + image = httpsDescriptor, + bytes = httpsDescriptor, + stored = mapOf(IMAGE_URI to null), + ), + DiskCacheWriteCase( + "read policy off", + policy = CachePolicy.WRITE_ONLY, + cached = BLOB_BYTES, + stored = mapOf(IMAGE_URI to IMAGE_BYTES), + ), + DiskCacheWriteCase("cleared during the fetch", clearsCache = true, stored = mapOf(IMAGE_URI to null)), + DiskCacheWriteCase("write policy off", policy = CachePolicy.READ_ONLY, stored = mapOf(IMAGE_URI to null)), + DiskCacheWriteCase( + "request disk cache key", + key = "custom-key", + stored = mapOf("custom-key" to IMAGE_BYTES, IMAGE_URI to null), + ), + ).forEachCase({ it.name }) { case -> + reset(pubkyService) + val epoch = PubkyImageCacheEpoch() + val diskCache = createDiskCache() + case.cached?.let { diskCache.put(IMAGE_URI, it) } + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenAnswer { + if (case.clearsCache) { + epoch.advance() + diskCache.clear() + } + case.image + } + whenever(pubkyService.fetchFile(BLOB_URI, PUBKY_IMAGE_MAX_BYTES)) + .thenAnswer { case.blob ?: throw FetcherTestError("blob fetch failed") } + val requestOptions = options.copy(diskCachePolicy = case.policy, diskCacheKey = case.key) + + val result = createFetcher(diskCache, requestOptions, epoch).fetch() + + assertEquals(DataSource.NETWORK, result.dataSource(), case.name) + assertContentEquals(case.bytes, result.bytes(), case.name) + case.stored.forEach { assertContentEquals(it.value, diskCache.read(it.key), "${case.name}: ${it.key}") } + } + } + + @Test + fun `fetch should abort the disk cache editor when writing fails`() = test { + val diskCache = mock() + val editor = mock() + whenever(diskCache.fileSystem).thenReturn(FileSystem.SYSTEM) + whenever(diskCache.openEditor(IMAGE_URI)).thenReturn(editor) + whenever(editor.data).thenReturn(tempFolder.root.toOkioPath() / "missing" / "data") + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(IMAGE_BYTES) + + val result = createFetcher(diskCache).fetch() + + assertContentEquals(IMAGE_BYTES, result.bytes()) + verify(editor).abort() + verify(editor, never()).commit() + } + + @Test + fun `fetch should return the network bytes when the disk cache fails`() = test { + listOf Unit>>( + "read" to { whenever(it.openSnapshot(IMAGE_URI)).thenAnswer { throw IOException("journal unreadable") } }, + "editor" to { whenever(it.openEditor(IMAGE_URI)).thenAnswer { throw IOException("journal write failed") } }, + ).forEachCase({ it.first }) { (name, fail) -> + reset(pubkyService) + val diskCache = mock() + fail(diskCache) + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(IMAGE_BYTES) + + val result = createFetcher(diskCache).fetch() + + assertEquals(DataSource.NETWORK, result.dataSource(), name) + assertContentEquals(IMAGE_BYTES, result.bytes(), name) + verify(pubkyService, description(name)).fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES) + verify(diskCache, description(name)).openEditor(IMAGE_URI) + } + } + + @Test + fun `fetch should rethrow a disk cache cancellation instead of falling back`() = test { + val editor = mock() + listOf Unit>>( + "read" to { whenever(it.openSnapshot(IMAGE_URI)).thenAnswer { throw CancellationException() } }, + "editor" to { whenever(it.openEditor(IMAGE_URI)).thenAnswer { throw CancellationException() } }, + "write" to { + whenever(it.fileSystem).thenReturn(FileSystem.SYSTEM) + whenever(it.openEditor(IMAGE_URI)).thenReturn(editor) + whenever(editor.data).thenReturn(tempFolder.newFile().toOkioPath()) + whenever(editor.commit()).thenAnswer { throw CancellationException() } + }, + ).forEachCase({ it.first }) { (name, cancel) -> + reset(pubkyService, editor) + val diskCache = mock() + cancel(diskCache) + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(IMAGE_BYTES) + + assertFailsWith(name) { createFetcher(diskCache).fetch() } + + if (name == "read") verifyNoInteractions(pubkyService) + if (name == "write") verify(editor, description(name)).abort() + } + } + + @Test + fun `a remembered permanent failure skips the network until the image cache is cleared`() = test { + listOf( + PermanentFailureCase("missing file", PubkyFileNotFoundError()), + PermanentFailureCase("missing blob", PubkyFileNotFoundError(), descriptor = true), + PermanentFailureCase("oversize blob", oversizeError(), descriptor = true), + PermanentFailureCase("profile not found", PaykitException.NotFound("not_found", "fetch Pubky file")), + ).forEachCase({ it.name }) { case -> + reset(pubkyService) + val epoch = PubkyImageCacheEpoch() + val failures = PubkyImageFailureCache(epoch, clock) + val failingUri = if (case.descriptor) BLOB_URI else IMAGE_URI + if (case.descriptor) { + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)).thenReturn(DESCRIPTOR_BYTES) + } + whenever(pubkyService.fetchFile(failingUri, PUBKY_IMAGE_MAX_BYTES)) + .thenAnswer { throw AppError(case.error) } + runCatching { createFetcher(epoch = epoch, failures = failures).fetch() } + nowMillis += 1.hours.inWholeMilliseconds + + assertFailsWith(case.name) { + createFetcher(epoch = epoch, failures = failures).fetch() + } + verify(pubkyService, description(case.name)).fetchFile(failingUri, PUBKY_IMAGE_MAX_BYTES) + + epoch.advance() + runCatching { createFetcher(epoch = epoch, failures = failures).fetch() } + verify(pubkyService, times(2).description(case.name)).fetchFile(failingUri, PUBKY_IMAGE_MAX_BYTES) + } + } + + @Test + fun `a transient failure is fetched again once its window has passed`() = test { + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)) + .thenAnswer { throw AppError(PaykitException.Transport("transport_error", "fetch Pubky file")) } + .thenReturn(IMAGE_BYTES) + assertFailsWith { createFetcher(failures = failures).fetch() } + nowMillis += PUBKY_IMAGE_TRANSIENT_FAILURE_TTL.inWholeMilliseconds - 1 + + assertFailsWith { createFetcher(failures = failures).fetch() } + verify(pubkyService).fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES) + nowMillis += 1 + + assertContentEquals(IMAGE_BYTES, createFetcher(failures = failures).fetch().bytes()) + verify(pubkyService, times(2)).fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES) + } + + @Test + fun `a failure is not remembered when the image cache is cleared during its fetch or the fetch is cancelled`() = + test { + listOf Throwable>>( + "cleared during the fetch" to { + cacheEpoch.advance() + AppError(PubkyFileNotFoundError()) + }, + "cancelled" to { CancellationException() }, + ).forEachCase({ it.first }) { (name, failure) -> + reset(pubkyService) + val failures = PubkyImageFailureCache(cacheEpoch, clock) + whenever(pubkyService.fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES)) + .thenAnswer { throw failure() } + .thenReturn(IMAGE_BYTES) + runCatching { createFetcher(failures = failures).fetch() } + + assertContentEquals(IMAGE_BYTES, createFetcher(failures = failures).fetch().bytes(), name) + verify(pubkyService, times(2).description(name)).fetchFile(IMAGE_URI, PUBKY_IMAGE_MAX_BYTES) + } + } + + private fun oversizeError() = PaykitException.Protocol( + "protocol_error", + "fetch Pubky file: resource exceeds maximum size of $PUBKY_IMAGE_MAX_BYTES bytes", + ) + + private fun createFetcher( + diskCache: DiskCache? = null, + options: Options = this.options, + epoch: PubkyImageCacheEpoch = cacheEpoch, + failures: PubkyImageFailureCache = PubkyImageFailureCache(epoch, clock), + ) = PubkyImageFetcher(IMAGE_URI, options, pubkyService, diskCache, epoch, failures) + + private fun createDiskCache() = DiskCache.Builder() + .directory(tempFolder.newFolder()) + .build() + + private fun DiskCache.put(key: String, bytes: ByteArray) { + val editor = checkNotNull(openEditor(key)) + fileSystem.write(editor.data) { write(bytes) } + editor.commit() + } + + private fun DiskCache.read(key: String): ByteArray? = openSnapshot(key)?.use { + fileSystem.read(it.data) { readByteArray() } + } + + private fun FetchResult?.dataSource() = (this as SourceFetchResult).dataSource + + private fun FetchResult?.bytes() = (this as SourceFetchResult).source.use { it.source().readByteArray() } + + @Suppress("LongParameterList") + private class DiskCacheWriteCase( + val name: String, + val image: ByteArray = IMAGE_BYTES, + val blob: ByteArray? = null, + val bytes: ByteArray = IMAGE_BYTES, + val policy: CachePolicy = CachePolicy.ENABLED, + val key: String? = null, + val cached: ByteArray? = null, + val clearsCache: Boolean = false, + val stored: Map, + ) } + +private class FetcherTestError(message: String) : AppError(message) + +private class PermanentFailureCase(val name: String, val error: Throwable, val descriptor: Boolean = false) diff --git a/app/src/test/java/to/bitkit/data/PubkyStoreTest.kt b/app/src/test/java/to/bitkit/data/PubkyStoreTest.kt new file mode 100644 index 0000000000..8fc72ae0f0 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/PubkyStoreTest.kt @@ -0,0 +1,34 @@ +package to.bitkit.data + +import kotlinx.coroutines.test.runTest +import org.junit.Test +import to.bitkit.data.serializers.PubkyStoreSerializer +import to.bitkit.test.forEachCase +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PubkyStoreTest { + @Test + fun `cachedProfile exposes the cached name and image only with their owner`() { + val withOwner = PubkyStoreData( + ownerPublicKey = "pubkybob", + cachedProfileOwner = "pubkyalice", + cachedName = "Alice", + cachedImageUri = "pubky://avatar", + ) + listOf( + Triple("with owner", withOwner, PubkyCachedProfile("pubkyalice", "Alice", "pubky://avatar")), + Triple("without owner", PubkyStoreData(ownerPublicKey = "pubkyalice", cachedName = "Alice"), null), + ).forEachCase({ it.first }) { (case, data, expected) -> assertEquals(expected, data.cachedProfile(), case) } + } + + @Test + fun `stored data without a cached profile owner exposes no cached profile`() = runTest { + val stored = """{"ownerPublicKey":"pubkyalice","cachedName":"Alice","cachedImageUri":"pubky://avatar"}""" + + val data = PubkyStoreSerializer.readFrom(stored.byteInputStream()) + + assertEquals("Alice", data.cachedName) + assertNull(data.cachedProfile()) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 9e2a460ae6..61027d4415 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -298,7 +298,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) .thenReturn(listOf(PaykitReceiverPaths.SERVER)) listOf(null, byteArrayOf(0, 1, 2)).forEach { icon -> @@ -441,7 +441,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) .thenReturn(listOf(PaykitReceiverPaths.SERVER)) whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) sut.refresh().getOrThrow() @@ -1036,7 +1036,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 657b72ac11..fbcaa8f5e9 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -42,6 +42,7 @@ import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.services.PaykitPaymentRequestProposalTerms +import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService import to.bitkit.test.BaseUnitTest @@ -599,7 +600,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) whenever( @@ -656,10 +657,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), ), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)).doSuspendableAnswer { + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(SECOND_IDENTITY), any())).doSuspendableAnswer { stalledDiscovery.await() listOf(PaykitReceiverPaths.SERVER) } @@ -682,7 +683,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(proposal.isCompleted) proposal.await().getOrThrow() - verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(SECOND_IDENTITY) } + verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(eq(SECOND_IDENTITY), any()) } } @Test @@ -694,7 +695,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) whenever(paykitSdkService.proposePaymentRequest(any(), any(), any(), eq(LOCAL_IDENTITY))).doSuspendableAnswer { @@ -735,7 +736,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refresh().getOrThrow() assertEquals(1, sut.pendingRequests.value.size) - verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(any()) } + verifyBlocking(paykitSdkService, never()) { paymentRequestReceiverPaths(any(), any()) } } @Test @@ -744,7 +745,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) @@ -755,7 +756,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(1)) { paymentRequestReceiverPaths(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(1)) { paymentRequestReceiverPaths(eq(COUNTERPARTY), any()) } } @Test @@ -764,7 +765,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) @@ -775,6 +776,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(expected), sut.eligibleTargets.value) } + @Test + fun `single recipient refresh reads on the interactive lane and a full refresh in bulk`() = test { + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), + ) + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( + listOf(PaykitReceiverPaths.SERVER), + ) + + sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow() + verifyBlocking(paykitSdkService) { paymentRequestReceiverPaths(COUNTERPARTY, PaykitReadLane.Interactive) } + sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() + + verifyBlocking(paykitSdkService) { paymentRequestReceiverPaths(COUNTERPARTY, PaykitReadLane.Bulk) } + } + @Test fun `single recipient refresh removes a contact that is no longer linked`() = test { whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) @@ -782,7 +800,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), emptyList(), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -799,7 +817,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) .thenReturn(listOf(PaykitReceiverPaths.SERVER), emptyList()) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -815,7 +833,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) .thenReturn(listOf(PaykitReceiverPaths.SERVER)) .thenThrow(IllegalStateException("marker unavailable")) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -837,7 +855,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { lookups += 1 when (lookups) { 1 -> listOf(PaykitReceiverPaths.SERVER) @@ -878,7 +896,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { releaseFullLinkLookup.await() error("linked peers unavailable") } - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)) + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(SECOND_IDENTITY), any())) .thenReturn(listOf(PaykitReceiverPaths.SERVER)) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY)) } @@ -902,7 +920,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { lookups += 1 if (lookups > 1) return@doSuspendableAnswer emptyList() fullLookupStarted.complete(Unit) @@ -929,7 +947,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { lookups += 1 if (lookups > 1) return@doSuspendableAnswer listOf(PaykitReceiverPaths.SERVER) singleLookupStarted.complete(Unit) @@ -960,14 +978,14 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { linkedPeer(SECOND_IDENTITY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER), ), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { counterpartyLookups += 1 if (counterpartyLookups > 1) return@doSuspendableAnswer emptyList() fullLookupStarted.complete(Unit) releaseFullLookup.await() listOf(PaykitReceiverPaths.SERVER) } - whenever(paykitSdkService.paymentRequestReceiverPaths(SECOND_IDENTITY)) + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(SECOND_IDENTITY), any())) .thenReturn(listOf(PaykitReceiverPaths.SERVER)) val fullRefresh = async { sut.refreshEligibleTargets(listOf(COUNTERPARTY, SECOND_IDENTITY)) } @@ -988,7 +1006,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()) .thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER))) .thenThrow(IllegalStateException("linked peers unavailable")) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -1005,7 +1023,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) .thenReturn(emptyList(), listOf(PaykitReceiverPaths.SERVER)) sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() @@ -1015,7 +1033,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(eq(COUNTERPARTY), any()) } } @Test @@ -1024,7 +1042,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)) + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())) .thenReturn(listOf(PaykitReceiverPaths.SERVER)) .thenThrow(IllegalStateException("marker unavailable")) @@ -1035,7 +1053,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { listOf(PaykitPaymentRequestTarget(COUNTERPARTY, PaykitReceiverPaths.SERVER)), sut.eligibleTargets.value, ) - verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(COUNTERPARTY) } + verifyBlocking(paykitSdkService, times(2)) { paymentRequestReceiverPaths(eq(COUNTERPARTY), any()) } } @Test @@ -1046,7 +1064,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).doSuspendableAnswer { + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).doSuspendableAnswer { discoveryStarted.complete(Unit) stalledDiscovery.await() listOf(PaykitReceiverPaths.SERVER) @@ -1069,7 +1087,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) @@ -1085,7 +1103,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.linkedPeers()).thenReturn( listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED, PaykitReceiverPaths.SERVER)), ) - whenever(paykitSdkService.paymentRequestReceiverPaths(COUNTERPARTY)).thenReturn( + whenever(paykitSdkService.paymentRequestReceiverPaths(eq(COUNTERPARTY), any())).thenReturn( listOf(PaykitReceiverPaths.SERVER), ) diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4005ab2fc6..4288f1e224 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -57,6 +57,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.PaykitPreparedPrivateContactPayment import to.bitkit.services.PaykitPrivateContactPaymentResolution import to.bitkit.services.PaykitPrivateReceiverPathSelection +import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitResolvedPaymentEndpoint import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyService @@ -78,6 +79,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { companion object { private const val CONTACT_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val OTHER_CONTACT_KEY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val NEW_CONTACT_KEY = "pubky4rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val OWN_KEY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val PRIVATE_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" private const val OTHER_PRIVATE_ADDRESS = "bcrt1q9x0pz2tqf8clz0lq6m9wj8t47zffnrdz2tkt6v" @@ -126,7 +128,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) - whenever { pubkyService.discoverRelevantReceiverPaths(any()) } + whenever { pubkyService.discoverRelevantReceiverPaths(any(), any()) } .thenReturn(listOf(WALLET_RECEIVER_PATH)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(true) whenever(walletRepo.walletExists()).thenReturn(true) @@ -134,7 +136,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever { addressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, WALLET_RECEIVER_PATH) } .thenReturn(Result.success(PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenAnswer { + whenever { paykitSdkService.privateReceiverPathSelection(any(), any(), any()) }.thenAnswer { privateReceiverPathSelection(it.getArgument(1)) } whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } @@ -242,7 +244,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = true) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } .thenReturn(privateReceiverPathSelection(emptyList())) val result = sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true) @@ -264,7 +266,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } .thenReturn( privateReceiverPathSelection( linkableReceiverPaths = listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), @@ -289,7 +291,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } .thenReturn( privateReceiverPathSelection( linkableReceiverPaths = listOf(SERVER_RECEIVER_PATH), @@ -305,12 +307,42 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(addressReservationRepo, never()).currentOrRotatedAddress(any(), any()) } + @Test + fun `refreshKnownSavedContactEndpoints succeeds when a contact is saved during receiver discovery`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)).isSuccess) + clearInvocations(pubkyService) + val discoveryStarted = CompletableDeferred() + val resumeDiscovery = CompletableDeferred() + whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Bulk) } + .doSuspendableAnswer { + discoveryStarted.complete(Unit) + resumeDiscovery.await() + listOf(WALLET_RECEIVER_PATH) + } + + val refresh = async { sut.refreshKnownSavedContactEndpoints("test") } + discoveryStarted.await() + val saveResult = sut.refreshSavedContactEndpoints( + NEW_CONTACT_KEY, + listOf(CONTACT_KEY, OTHER_CONTACT_KEY, NEW_CONTACT_KEY), + ) + resumeDiscovery.complete(Unit) + val result = refresh.await() + + assertTrue(saveResult.isSuccess, saveResult.exceptionOrNull().toString()) + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(NEW_CONTACT_KEY, PaykitReadLane.Bulk) } + verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(OTHER_CONTACT_KEY, PaykitReadLane.Bulk) } + sut.closeAndClear() + } + @Test fun `initial link burst discovers a server receiver published after the contact was saved`() = test { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) whenever { paykitSdkService.contactRecord(CONTACT_KEY) } .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH))) - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } + whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Bulk) } .thenReturn(listOf(WALLET_RECEIVER_PATH)) .thenReturn(listOf(WALLET_RECEIVER_PATH)) .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) @@ -347,7 +379,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) whenever(paykitSdkService.contactRecord(CONTACT_KEY)) .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH)), null) - whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } + whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Bulk) } .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") @@ -378,8 +410,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(2_000) runCurrent() - verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(OTHER_CONTACT_KEY) } - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(CONTACT_KEY) } + verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(OTHER_CONTACT_KEY, PaykitReadLane.Bulk) } + verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Bulk) } sut.closeAndClear() } @@ -394,7 +426,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(30_000) runCurrent() - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any()) } + verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any(), any()) } verifyBlocking(paykitSdkService, never()) { syncPrivatePaymentListsWithReservations(any(), any()) } sut.closeAndClear() } @@ -411,7 +443,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(30_000) runCurrent() - verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any()) } + verifyBlocking(pubkyService, never()) { discoverRelevantReceiverPaths(any(), any()) } sut.closeAndClear() } @@ -426,7 +458,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH))) whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) }.thenAnswer { @@ -460,7 +492,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) whenever { addressReservationRepo.currentOrRotatedAddress(CONTACT_KEY, SERVER_RECEIVER_PATH) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } .thenReturn(privateReceiverPathSelection(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) .thenReturn( privateReceiverPathSelection( @@ -494,7 +526,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } .thenReturn( privateReceiverPathSelection( publishableReceiverPaths = emptyList(), @@ -518,7 +550,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) whenever { addressReservationRepo.currentOrRotatedAddress(OTHER_CONTACT_KEY, WALLET_RECEIVER_PATH) } .thenReturn(Result.success(OTHER_PRIVATE_ADDRESS)) - whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any()) } + whenever { paykitSdkService.privateReceiverPathSelection(eq(CONTACT_KEY), any(), any()) } .thenReturn( privateReceiverPathSelection( publishableReceiverPaths = emptyList(), @@ -569,7 +601,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { publicPaykitLightningEnabled = false, publicPaykitOnchainEnabled = true, ) - whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenReturn( + whenever { paykitSdkService.privateReceiverPathSelection(any(), any(), any()) }.thenReturn( privateReceiverPathSelection( publishableReceiverPaths = emptyList(), linkableReceiverPaths = emptyList(), @@ -1239,6 +1271,30 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { stalledPublish.complete(Unit) } + @Test + fun `beginSavedContactPayment reads the paid contact's receivers on the interactive lane`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + sut.prepareSavedContacts(listOf(CONTACT_KEY)) + clearInvocations(paykitSdkService, pubkyService) + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment(CONTACT_KEY, WALLET_RECEIVER_PATH, null) + }.thenReturn(resolution(resolvedEndpoint(MethodId.Bolt11, PRIVATE_BOLT11), version = 7uL)) + whenever(coreService.decode(PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(PRIVATE_BOLT11, byteArrayOf(9, 9, 9)))) + + sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + advanceUntilIdle() + + verifyBlocking(pubkyService) { discoverRelevantReceiverPaths(CONTACT_KEY, PaykitReadLane.Interactive) } + verifyBlocking(paykitSdkService) { + privateReceiverPathSelection(eq(CONTACT_KEY), any(), eq(PaykitReadLane.Interactive)) + } + } + @Test fun `beginSavedContactPayment runs one endpoint publish per contact at a time`() = test { settingsData.value = SettingsData( diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index c85a168be5..4d76dedf8f 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -9,6 +9,7 @@ import com.synonym.paykit.ContactProfileSource import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicationStatus @@ -24,27 +25,45 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext import org.junit.Before import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.reset import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import org.mockito.verification.VerificationMode +import to.bitkit.data.PubkyCachedProfile +import to.bitkit.data.PubkyImageCacheEpoch import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData @@ -58,9 +77,12 @@ import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 +import to.bitkit.services.PaykitReadLane +import to.bitkit.services.PaykitSdkService import to.bitkit.services.PubkyRingAuthTimeoutError import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.forEachCase import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -68,17 +90,36 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue +import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant import com.synonym.paykit.PubkyProfile as SdkPubkyProfile +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) @Suppress("LargeClass") class PubkyRepoTest : BaseUnitTest() { companion object { + /** Public reads `PaykitSdkService` runs at once. */ + private const val PUBLIC_READ_SLOTS = 6 + + /** Time a busy follow read holds its read slot in the read slot tests, less than the follow timeout. */ + private val BUSY_READ_TIME = 7_000.milliseconds + // Valid 52-char z-base-32 key (+ "pubky" prefix = 57 chars) private const val VALID_CONTACT_KEY_A = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" private const val NON_CANONICAL_CONTACT_KEY_A = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val VALID_CONTACT_KEY_B = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" private const val VALID_SELF_KEY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + + /** More follows than `PaykitSdkService` runs public reads at once, so some wait for a read slot. */ + private const val MORE_FOLLOWS_THAN_READ_SLOTS = 8 + + /** Saved contacts whose background lookups finish one after another in the batching test. */ + private const val BATCHED_CONTACTS = 20 + + /** Virtual time between two background lookup results in the batching test. */ + private val BATCHED_LOOKUP_INTERVAL = 50.milliseconds } private lateinit var sut: PubkyRepo @@ -87,11 +128,15 @@ class PubkyRepoTest : BaseUnitTest() { private val keychain = mock() private val sharedPubkyClient = mock() private val imageLoader = mock() + private val imageCacheEpoch = PubkyImageCacheEpoch() private val pubkyStore = mock() private val settingsStore = mock() private val settingsFlow = MutableStateFlow(SettingsData()) private val profileSetupPending = MutableStateFlow(false) private var adoptedSource: String? = null + private val clock = object : Clock { + override fun now(): Instant = Instant.fromEpochMilliseconds(testDispatcher.scheduler.currentTime) + } @Before fun setUp() = runBlocking { @@ -128,9 +173,11 @@ class PubkyRepoTest : BaseUnitTest() { keychain = keychain, sharedPubkyClient = sharedPubkyClient, imageLoader = imageLoader, + imageCacheEpoch = imageCacheEpoch, pubkyStore = pubkyStore, settingsStore = settingsStore, httpClient = httpClient, + clock = clock, ) private fun stubAdoptedRingSource() { @@ -139,43 +186,58 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `import saves prepared profiles without network lookups and ignores duplicates`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + clearInvocations(pubkyService) 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.saveContact( + profile.publicKey, + profile.name, + restorePrivateConnection = true, + expectedIdentity = VALID_SELF_KEY, + ), + ).thenReturn(mock()) } - whenever(pubkyService.resolveContactProfile(any(), any())).thenAnswer { throw TestAppError("Offline") } - whenever(pubkyService.discoverRelevantReceiverPaths(any())).thenAnswer { throw TestAppError("Offline") } + whenever(pubkyService.resolveContactProfile(any(), any(), any(), anyOrNull())) + .thenAnswer { throw TestAppError("Offline") } + whenever(pubkyService.discoverRelevantReceiverPaths(any(), 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).saveContact( + profile.publicKey, + profile.name, + restorePrivateConnection = true, + expectedIdentity = VALID_SELF_KEY, + ) } - verify(pubkyService, never()).resolveContactProfile(any(), any()) - verify(pubkyService, never()).discoverRelevantReceiverPaths(any()) + verify(pubkyService, never()).resolveContactProfile(any(), any(), any(), anyOrNull()) + verify(pubkyService, never()).discoverRelevantReceiverPaths(any(), any()) } @Test fun `failed import keeps successful contacts and retry saves only missing contacts`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) 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)) + whenever(pubkyService.saveContact(alice.publicKey, alice.name, null, true, VALID_SELF_KEY)) .thenReturn(mock()) - whenever(pubkyService.saveContact(bob.publicKey, bob.name, restorePrivateConnection = true)) + whenever(pubkyService.saveContact(bob.publicKey, bob.name, null, true, VALID_SELF_KEY)) .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) + verify(pubkyService).saveContact(alice.publicKey, alice.name, null, true, VALID_SELF_KEY) + verify(pubkyService, times(2)).saveContact(bob.publicKey, bob.name, null, true, VALID_SELF_KEY) } @Test @@ -190,11 +252,10 @@ class PubkyRepoTest : BaseUnitTest() { ) 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)) + stubFollowLookup(VALID_CONTACT_KEY_B, "Alice") + whenever(pubkyService.saveContact(alice.publicKey, alice.name, null, true, VALID_CONTACT_KEY_A)) .thenReturn(mock()) - whenever(pubkyService.saveContact(VALID_CONTACT_KEY_A, ownProfile.name, restorePrivateConnection = true)) + whenever(pubkyService.saveContact(VALID_CONTACT_KEY_A, ownProfile.name, null, true, VALID_CONTACT_KEY_A)) .thenAnswer { throw TestAppError("Cannot save own identity") } assertTrue(sut.prepareImport().isSuccess) @@ -679,6 +740,23 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(2, cacheUpdateCount) } + @Test + fun `createIdentity retries a profile that is not yet readable after publishing`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + profileSetupPending.value = true + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(null) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Published"))) + clearInvocations(pubkyService) + + val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) + + assertTrue(result.isSuccess) + assertEquals("Published", sut.profile.value?.name) + verify(pubkyService, times(2)).resolveContactProfile(VALID_SELF_KEY, true) + } + @Test fun `wipe completes while identity creation waits for contact loading`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) @@ -758,11 +836,23 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isLoadingProfile.value) } + @Test + fun `loadProfile retries a failed own profile resolution once`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenAnswer { throw TestAppError("Network error") } + clearInvocations(pubkyService) + + sut.loadProfile() + + verify(pubkyService, times(2)).resolveContactProfile(VALID_SELF_KEY, true) + } + @Test fun `loadProfile should return early when no public key`() = test { sut.loadProfile() - verify(pubkyService, never()).resolveContactProfile(any(), any()) + verify(pubkyService, never()).resolveContactProfile(any(), any(), any(), anyOrNull()) } @Test @@ -779,6 +869,135 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyStore, atLeastOnce()) { update(any()) } } + @Test + fun `loadProfile drops a result that a profile save or deletion overtook`() = test { + listOf( + ProfileOvertake("save", profileName = "New", owner = VALID_SELF_KEY) { saveNewProfile() }, + ProfileOvertake("deletion", profileName = null, owner = null) { sut.deleteProfile() }, + ).forEachCase({ it.name }) { case -> + resetForCase() + val store = stubGatedPubkyStore() + authenticateForTesting(publicKey = VALID_SELF_KEY, profileName = "Old") + val loadStarted = CompletableDeferred() + val finishLoad = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + loadStarted.complete(Unit) + finishLoad.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Loaded")) + } + val load = async { sut.loadProfile() } + loadStarted.await() + + val result = case.overtake() + finishLoad.complete(Unit) + load.await() + + assertTrue(result.isSuccess, case.name) + assertProfileOutcome(case, store.data) + } + } + + @Test + fun `loadProfile marks the profile as loading before its first suspension`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val finishLoad = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + finishLoad.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + } + assertFalse(sut.isLoadingProfile.value) + + val load = launch(start = CoroutineStart.UNDISPATCHED) { sut.loadProfile() } + + assertTrue(sut.isLoadingProfile.value) + finishLoad.complete(Unit) + load.join() + assertFalse(sut.isLoadingProfile.value) + } + + @Test + fun `loadProfile does not restore cached metadata that a save or sign out replaced after its in-memory check`() = + test { + listOf( + ProfileOvertake("save", profileName = "New", owner = VALID_SELF_KEY) { saveNewProfile() }, + ProfileOvertake("sign out", profileName = null, owner = null) { sut.signOut() }, + ProfileOvertake("sign out writing right after its reset", null, null, resetReleasesWrite = true) { + sut.signOut() + }, + ).forEachCase({ it.name }) { case -> + resetForCase() + val store = stubGatedPubkyStore() + if (case.resetReleasesWrite) { + whenever(pubkyStore.reset()).doSuspendableAnswer { + store.data = PubkyStoreData() + store.releaseGatedUpdate.complete(Unit) + store.gatedUpdateApplied.await() + } + } + authenticateForTesting(publicKey = VALID_SELF_KEY, profileName = "Old") + gateNextProfileLoadStoreWrite(store, loadedName = "Loaded") + val load = async { sut.loadProfile() } + store.gatedUpdateStarted.await() + + val result = case.overtake() + if (case.resetReleasesWrite) { + assertTrue(store.releaseGatedUpdate.isCompleted, case.name) + } else { + store.releaseGatedUpdate.complete(Unit) + } + load.await() + + assertTrue(result.isSuccess, case.name) + assertTrue(store.gatedUpdateApplied.isCompleted, case.name) + assertProfileOutcome(case, store.data) + } + } + + @Test + fun `display, import and contact reads resolve once without retrying a missing profile or an error`() = test { + listOf Unit>>( + Triple("fetchDisplayProfile", PaykitReadLane.Interactive) { case -> + val missing = sut.fetchDisplayProfile(VALID_CONTACT_KEY_A.removePrefix("pubky")) + val failed = sut.fetchDisplayProfile(VALID_CONTACT_KEY_B) + assertTrue(missing.isSuccess, case) + assertNull(missing.getOrNull(), case) + assertTrue(failed.isFailure, case) + }, + Triple("prepareImport", PaykitReadLane.Interactive) { case -> + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.getContacts(VALID_SELF_KEY)) + .thenReturn(listOf(VALID_CONTACT_KEY_A, VALID_CONTACT_KEY_B)) + assertTrue(sut.prepareImport().isSuccess, case) + assertEquals( + setOf(PubkyProfile.placeholder(VALID_CONTACT_KEY_A), PubkyProfile.placeholder(VALID_CONTACT_KEY_B)), + sut.pendingImportContacts.value.toSet(), + case, + ) + }, + Triple("loadContacts", PaykitReadLane.Bulk) { case -> + authenticateForTesting() + val records = listOf(createContactRecord(VALID_CONTACT_KEY_A), createContactRecord(VALID_CONTACT_KEY_B)) + whenever(pubkyService.contactRecords()).thenReturn(records) + sut.loadContacts() + val keys = sut.contacts.value.map { it.publicKey }.toSet() + assertEquals(setOf(VALID_CONTACT_KEY_A, VALID_CONTACT_KEY_B), keys, case) + }, + ).forEachCase({ it.first }) { (case, lane, readAndAssert) -> + resetForCase() + whenever(pubkyService.resolveContactProfile(eq(VALID_CONTACT_KEY_A), eq(true), eq(lane), anyOrNull())) + .thenReturn(null) + whenever(pubkyService.resolveContactProfile(eq(VALID_CONTACT_KEY_B), eq(true), eq(lane), anyOrNull())) + .thenAnswer { throw TestAppError("Unreachable") } + + readAndAssert(case) + + listOf(VALID_CONTACT_KEY_A, VALID_CONTACT_KEY_B).forEach { + verify(pubkyService, times(1).description(case)) + .resolveContactProfile(eq(it), eq(true), eq(lane), anyOrNull()) + } + } + } + @Test fun `saveProfile should mark backup state changed`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY, secret = "test_session", profileName = "Alice") @@ -907,12 +1126,6 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `signOut should evict pubky images from caches`() = test { authenticateForTesting() - val pk = checkNotNull(sut.publicKey.value) - val pubkyProfile = createPubkyProfile(name = "Test", image = "pubky://image_uri") - whenever(pubkyService.resolveContactProfile(pk, true)) - .thenReturn(createResolution(pk, pubkyProfile = pubkyProfile)) - sut.loadProfile() - val memoryCache = mock() val diskCache = mock() val memoryCacheKey = MemoryCache.Key("pubky://image_uri") @@ -923,7 +1136,50 @@ class PubkyRepoTest : BaseUnitTest() { sut.signOut() verify(memoryCache).remove(memoryCacheKey) - verify(diskCache).remove("pubky://image_uri") + verify(diskCache).clear() + verify(diskCache, never()).remove(any()) + } + + @Test + fun `signOut advances the pubky image cache epoch before clearing the disk cache and survives a failed clear`() = + test { + listOf("clear succeeds" to false, "clear fails" to true).forEachCase({ it.first }) { (case, clearFails) -> + resetForCase() + authenticateForTesting() + val diskCache = mock() + whenever(imageLoader.diskCache).thenReturn(diskCache) + val epochBeforeSignOut = imageCacheEpoch.current() + var epochAtClear: Long? = null + doAnswer { + epochAtClear = imageCacheEpoch.current() + if (clearFails) error("disk cache unavailable") + }.whenever(diskCache).clear() + + val result = sut.signOut() + + assertTrue(result.isSuccess, case) + assertNull(sut.publicKey.value, case) + assertEquals(epochBeforeSignOut + 1, epochAtClear, case) + } + } + + @Test + fun `adoptRingIdentity clears the pubky image disk cache only when the identity changes`() = test { + listOf( + Triple("another identity", VALID_CONTACT_KEY_A, times(1)), + Triple("same identity", VALID_SELF_KEY, never()), + ).forEachCase({ it.first }) { (case, previousKey, clears) -> + resetForCase() + authenticateForTesting(publicKey = previousKey, profileName = "Previous") + val diskCache = mock() + whenever(imageLoader.diskCache).thenReturn(diskCache) + val ringPubky = stubRingCredential() + whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) + + sut.adoptRingIdentity(ringPubky) + + verify(diskCache, clears.description(case)).clear() + } } @Test @@ -1083,8 +1339,7 @@ class PubkyRepoTest : BaseUnitTest() { sut.addContact(existingContact.publicKey, existingProfile = existingContact) whenever(pubkyService.getContacts(publicKey)).thenReturn(listOf(pendingContactKey)) - whenever(pubkyService.resolveContactProfile(pendingContactKey, true)) - .thenReturn(createResolution(pendingContactKey, paykitProfile = createPaykitProfile("Pending Contact"))) + stubFollowLookup(pendingContactKey, "Pending Contact") val prepareResult = sut.prepareImport() @@ -1099,6 +1354,154 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(listOf(existingContact), sut.contacts.value) } + @Test + fun `prepareImport reuses the loaded profile of the active identity`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY, profileName = "Alice") + whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(emptyList()) + clearInvocations(pubkyService) + + val result = sut.prepareImport() + + assertTrue(result.isSuccess) + assertEquals("Alice", sut.pendingImportProfile.value?.name) + assertEquals(sut.profile.value, sut.pendingImportProfile.value) + verify(pubkyService, never()).resolveContactProfile(any(), any(), any(), anyOrNull()) + } + + @Test + fun `prepareImport looks up the follows and the own profile on the interactive lane`() = test { + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") + whenever(pubkyService.importSession("test_secret")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.contactRecords()).thenReturn(emptyList()) + sut.initialize() + assertNull(sut.profile.value) + whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true, PaykitReadLane.Interactive)) + .thenReturn(createResolution(VALID_SELF_KEY, paykitProfile = createPaykitProfile("Me"))) + stubFollowLookup(VALID_CONTACT_KEY_A, "Alice") + + assertTrue(sut.prepareImport().isSuccess) + + assertEquals("Me", sut.pendingImportProfile.value?.name) + assertEquals(listOf("Alice"), sut.pendingImportContacts.value.map { it.name }) + verify(pubkyService, never()).resolveContactProfile(any(), any(), eq(PaykitReadLane.Bulk), anyOrNull()) + } + + @Test + fun `prepareImport discards its results when the identity changes meanwhile`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val followsStarted = CompletableDeferred() + val finishFollows = CompletableDeferred() + whenever(pubkyService.getContacts(VALID_SELF_KEY)).doSuspendableAnswer { + followsStarted.complete(Unit) + finishFollows.await() + listOf(VALID_CONTACT_KEY_A) + } + stubFollowLookup(VALID_CONTACT_KEY_A, "Bob") + val preparation = async { sut.prepareImport() } + followsStarted.await() + + sut.wipeLocalState() + finishFollows.complete(Unit) + + assertTrue(preparation.await().isFailure) + assertNull(sut.pendingImportProfile.value) + assertTrue(sut.pendingImportContacts.value.isEmpty()) + } + + @Test + fun `a follow lookup times out only after holding a read slot that long and then stays a placeholder`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val sdk = mock() + delegateProfileReadsTo(PaykitSdkService(mock(), mock(), mock()) { sdk }) + val firstRound = List(PUBLIC_READ_SLOTS) { "pubkyfirst-follow-$it" } + val secondRound = List(PUBLIC_READ_SLOTS) { "pubkysecond-follow-$it" } + val queued = "pubkyqueued-follow" + val stuck = "pubkystuck-follow" + whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(firstRound + secondRound + queued + stuck) + val firstRoundDone = CompletableDeferred() + val secondRoundDone = CompletableDeferred() + var stuckReadCancelled = false + whenever(sdk.resolveContactProfile(any(), any(), any())).doSuspendableAnswer { + val key = it.getArgument(0) + when (key) { + in firstRound -> firstRoundDone.await() + in secondRound -> secondRoundDone.await() + stuck -> try { + awaitCancellation() + } finally { + stuckReadCancelled = true + } + } + createResolution(key, paykitProfile = createPaykitProfile("Follow $key")) + } + + val preparation = async { sut.prepareImport() } + advanceTimeBy(BUSY_READ_TIME) + firstRoundDone.complete(Unit) + advanceTimeBy(BUSY_READ_TIME) + secondRoundDone.complete(Unit) + advanceTimeBy(PubkyRepo.IMPORT_FOLLOW_LOOKUP_TIMEOUT - 1.milliseconds) + runCurrent() + + assertFalse(preparation.isCompleted) + advanceTimeBy(1.milliseconds) + runCurrent() + assertTrue(preparation.isCompleted) + assertTrue(preparation.await().isSuccess) + assertTrue(stuckReadCancelled) + assertEquals( + ((firstRound + secondRound + queued).map { "Follow $it" } + PubkyProfile.placeholder(stuck).name) + .sortedBy { it.lowercase() }, + sut.pendingImportContacts.value.map { it.name }, + ) + } + + @Test + fun `a follow lookup that timed out gives its read slot to a later read`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val sdk = mock() + delegateProfileReadsTo(PaykitSdkService(mock(), mock(), mock()) { sdk }) + val stuckFollows = List(MORE_FOLLOWS_THAN_READ_SLOTS) { "pubkystuck-follow-$it" } + whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(stuckFollows) + var startedReads = 0 + var cancelledReads = 0 + whenever(sdk.resolveContactProfile(any(), any(), any())).doSuspendableAnswer { + val key = it.getArgument(0) + if (key == VALID_CONTACT_KEY_A) { + return@doSuspendableAnswer createResolution(key, paykitProfile = createPaykitProfile("Alice")) + } + startedReads++ + try { + awaitCancellation() + } finally { + cancelledReads++ + } + } + + val preparation = async { sut.prepareImport() } + val laterRead = async { sut.fetchDisplayProfile(VALID_CONTACT_KEY_A) } + runCurrent() + assertEquals(PUBLIC_READ_SLOTS, startedReads) + assertFalse(laterRead.isCompleted) + advanceTimeBy(PubkyRepo.IMPORT_FOLLOW_LOOKUP_TIMEOUT) + runCurrent() + + assertTrue(laterRead.isCompleted) + assertEquals("Alice", laterRead.await().getOrNull()?.name) + assertFalse(preparation.isCompleted) + advanceTimeBy(PubkyRepo.IMPORT_FOLLOW_LOOKUP_TIMEOUT) + runCurrent() + assertTrue(preparation.isCompleted) + assertTrue(preparation.await().isSuccess) + assertEquals( + stuckFollows.map { PubkyProfile.placeholder(it).name }.sorted(), + sut.pendingImportContacts.value.map { it.name }.sorted(), + ) + assertEquals(stuckFollows.size, startedReads) + assertEquals(startedReads, cancelledReads) + } + @Test fun `displayName should return null when no profile and no cache`() = test { sut.displayName.test(timeout = 500.milliseconds) { @@ -1134,6 +1537,42 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting(publicKey = VALID_SELF_KEY) assertEquals(VALID_SELF_KEY, cached.ownerPublicKey) + assertEquals(VALID_SELF_KEY, cached.cachedProfileOwner) + } + + @Test + fun `contact profile overrides keep the cached profile owner of the previous identity`() = test { + var cached = PubkyStoreData() + whenever { pubkyStore.update(any()) }.thenAnswer { + cached = it.getArgument<(PubkyStoreData) -> PubkyStoreData>(0)(cached) + Unit + } + authenticateForTesting(publicKey = VALID_CONTACT_KEY_A, profileName = "Previous") + val ringPubky = stubRingCredential() + whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + sut.adoptRingIdentity(ringPubky) + + sut.restoreContactProfileOverrides(emptyMap()) + + assertEquals(VALID_SELF_KEY, cached.ownerPublicKey) + assertEquals(VALID_CONTACT_KEY_A, cached.cachedProfileOwner) + assertEquals("Previous", cached.cachedName) + } + + @Test + fun `cachedProfile exposes the cached profile with its owner`() = test { + val data = PubkyStoreData( + cachedProfileOwner = VALID_SELF_KEY, + cachedName = "Cached", + cachedImageUri = "pubky://a", + ) + whenever(pubkyStore.data).thenReturn(flowOf(data)) + sut = createSut() + + sut.cachedProfile.test(timeout = 500.milliseconds) { + assertEquals(PubkyCachedProfile(VALID_SELF_KEY, "Cached", "pubky://a"), awaitItem()) + } } @Test @@ -1261,6 +1700,59 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.contacts.value.isEmpty()) } + @Test + fun `adoptRingIdentity seeds the handed off profile without resolving it again`() = test { + var cached = PubkyStoreData() + whenever(pubkyStore.update(any())).thenAnswer { + cached = it.getArgument<(PubkyStoreData) -> PubkyStoreData>(0)(cached) + Unit + } + profileSetupPending.value = true + val ringPubky = stubRingCredential() + whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) + val knownProfile = PubkyProfile.forDisplay(ringPubky, name = "Ring Profile", imageUrl = "pubky://avatar") + + val result = sut.adoptRingIdentity(ringPubky) { knownProfile } + + assertEquals(true, result.getOrNull()) + assertEquals(knownProfile.copy(publicKey = VALID_SELF_KEY), sut.profile.value) + assertFalse(profileSetupPending.value) + assertEquals(VALID_SELF_KEY, cached.ownerPublicKey) + assertEquals("Ring Profile", cached.cachedName) + verify(pubkyService, never()).resolveContactProfile(any(), any(), any(), anyOrNull()) + } + + @Test + fun `adoptRingIdentity takes a profile handed off for its key once sign-in completes, else resolves it`() = test { + listOf( + RingHandoff("handed off", "Ring Profile", remoteName = null, adopted = true, resolves = never()) { + PubkyProfile.forDisplay(it, name = "Ring Profile", imageUrl = null) + }, + RingHandoff("none handed off", "Remote", remoteName = "Remote", adopted = true, resolves = times(1)) { + null + }, + RingHandoff("another key", null, remoteName = null, adopted = false, resolves = atLeastOnce()) { + PubkyProfile.forDisplay(VALID_CONTACT_KEY_A, name = "Other", imageUrl = null) + }, + ).forEachCase({ it.name }) { case -> + resetForCase() + val ringPubky = stubRingCredential() + var signedIn = false + whenever(pubkyService.signIn("ring_secret")).doSuspendableAnswer { signedIn = true } + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn( + case.remoteName?.let { createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = it)) }, + ) + val handoff = case.handoff(ringPubky) + + val result = sut.adoptRingIdentity(ringPubky) { handoff.takeIf { signedIn } } + + assertEquals(case.adopted, result.getOrNull(), case.name) + assertEquals(case.profileName, sut.profile.value?.name, case.name) + assertEquals(!case.adopted, profileSetupPending.value, case.name) + verify(pubkyService, case.resolves.description(case.name)).resolveContactProfile(VALID_SELF_KEY, true) + } + } + @Test fun `adoptRingIdentity revokes a session installed by a failed attempt`() = test { val ringPubky = VALID_SELF_KEY.removePrefix("pubky") @@ -2013,6 +2505,139 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(false, sut.identityExists.value) } + @Test + fun `awaitIdentityReady waits for an in-flight restore retry and restores once`() = test { + val restore = stubSavedSessionRestore() + sut.initialize() + assertNull(sut.publicKey.value) + clearInvocations(pubkyService) + val retryStarted = CompletableDeferred() + val finishRetry = CompletableDeferred() + restore.answer = { + retryStarted.complete(Unit) + finishRetry.await() + VALID_SELF_KEY + } + val retry = async { sut.restoreSessionIfNeeded() } + retryStarted.await() + + val readiness = async { sut.awaitIdentityReady() } + assertFalse(readiness.isCompleted) + finishRetry.complete(Unit) + + assertEquals(PubkyIdentityReadiness.Ready, readiness.await()) + retry.await() + verify(pubkyService, times(1)).importSession("saved_session") + } + + @Test + fun `awaitIdentityReady retries a failed startup restore without waiting for contacts`() = test { + val restore = stubSavedSessionRestore() + sut.initialize() + assertNull(sut.publicKey.value) + restore.answer = { VALID_SELF_KEY } + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) + val contactsRequested = CompletableDeferred() + val finishContacts = CompletableDeferred>() + whenever(pubkyService.contactRecords()).doSuspendableAnswer { + contactsRequested.complete(Unit) + finishContacts.await() + } + + assertEquals(PubkyIdentityReadiness.Ready, sut.awaitIdentityReady()) + + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + contactsRequested.await() + assertTrue(sut.isLoadingContacts.value) + finishContacts.complete(emptyList()) + assertFalse(sut.isLoadingContacts.value) + } + + @Test + fun `awaitIdentityReady reports a missing or unreadable identity without a network call`() = test { + listOf( + Triple("missing", false, PubkyIdentityReadiness.Missing), + Triple("unreadable", true, PubkyIdentityReadiness.Unavailable), + ).forEachCase({ it.first }) { (case, keychainFails, readiness) -> + resetForCase() + sut.awaitInitialization() + if (keychainFails) { + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) + .thenAnswer { throw TestAppError("Keychain unavailable") } + } + clearInvocations(pubkyService) + + assertEquals(readiness, sut.awaitIdentityReady(), case) + + verify(pubkyService, never().description(case)).initialize() + verify(pubkyService, never().description(case)).importSession(any()) + verify(pubkyService, never().description(case)).signIn(any()) + } + } + + @Test + fun `awaitIdentityReady reports a saved identity it cannot restore without clearing it`() = test { + stubSavedSessionRestore() + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local_secret") + whenever(pubkyService.signIn("local_secret")).thenAnswer { throw TestAppError("Clock skew") } + sut.initialize() + sut.clearSessionRestorationFailed() + clearInvocations(pubkyStore, keychain) + + assertEquals(PubkyIdentityReadiness.Unavailable, sut.awaitIdentityReady()) + + assertNull(sut.publicKey.value) + assertFalse(sut.sessionRestorationFailed.value) + verify(pubkyStore, never()).reset() + verifyBlocking(keychain, never()) { delete(any()) } + } + + @Test + fun `awaitIdentityReady waits for an in-flight adoption`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + val ringPubky = stubRingCredential() + val signInStarted = CompletableDeferred() + val finishSignIn = CompletableDeferred() + whenever(pubkyService.signIn("ring_secret")).doSuspendableAnswer { + signInStarted.complete(Unit) + finishSignIn.await() + } + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) + + val adoption = async { sut.adoptRingIdentity(ringPubky) } + signInStarted.await() + val readiness = async { sut.awaitIdentityReady() } + assertFalse(readiness.isCompleted) + finishSignIn.complete(Unit) + + assertTrue(adoption.await().isSuccess) + assertEquals(PubkyIdentityReadiness.Ready, readiness.await()) + verify(pubkyService, never()).importSession(any()) + } + + @Test + fun `cancelling an identity wait does not cancel its restore`() = test { + val restore = stubSavedSessionRestore() + sut.initialize() + val retryStarted = CompletableDeferred() + val finishRetry = CompletableDeferred() + restore.answer = { + retryStarted.complete(Unit) + finishRetry.await() + VALID_SELF_KEY + } + val caller = launch { sut.awaitIdentityReady() } + retryStarted.await() + + caller.cancelAndJoin() + finishRetry.complete(Unit) + + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + @Test fun `adoption excludes a queued restoration retry`() = test { sut.awaitInitialization() @@ -2401,7 +3026,7 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting() val contactKey = "pubkycontact2" whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(contactKey))) - whenever(pubkyService.resolveContactProfile(contactKey, true)) + whenever(pubkyService.resolveContactProfile(contactKey, true, PaykitReadLane.Bulk)) .thenAnswer { throw TestAppError("Network error") } sut.loadContacts() @@ -2412,6 +3037,549 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isLoadingContacts.value) } + @Test + fun `addContact retries a failed contact resolution once`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true)) + .thenAnswer { throw TestAppError("Network error") } + .thenReturn(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice"))) + + assertTrue(sut.addContact(VALID_CONTACT_KEY_A).isSuccess) + + assertEquals(listOf("Alice"), sut.contacts.value.map { it.name }) + verify(pubkyService, times(2)).resolveContactProfile(VALID_CONTACT_KEY_A, true) + } + + @Test + fun `importContacts keeps saving after its caller is cancelled`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val alice = PubkyProfile.placeholder(VALID_CONTACT_KEY_A).copy(name = "Alice") + val saveStarted = CompletableDeferred() + val finishSave = CompletableDeferred() + whenever(pubkyService.saveContact(eq(VALID_CONTACT_KEY_A), any(), anyOrNull(), any(), any())) + .doSuspendableAnswer { + saveStarted.complete(Unit) + finishSave.await() + createContactRecord(VALID_CONTACT_KEY_A) + } + assertFalse(sut.isImportingContacts.value) + val caller = launch { sut.importContacts(listOf(alice)) } + saveStarted.await() + assertTrue(sut.isImportingContacts.value) + + caller.cancelAndJoin() + finishSave.complete(Unit) + + assertEquals(listOf(alice), sut.contacts.value) + assertFalse(sut.isImportingContacts.value) + } + + @Test + fun `importContacts stops saving quietly once the identity changes`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val alice = PubkyProfile.placeholder(VALID_CONTACT_KEY_A).copy(name = "Alice") + val bob = PubkyProfile.placeholder(VALID_CONTACT_KEY_B).copy(name = "Bob") + val saveStarted = CompletableDeferred() + val finishSave = CompletableDeferred() + whenever(pubkyService.saveContact(eq(VALID_CONTACT_KEY_A), any(), anyOrNull(), any(), any())) + .doSuspendableAnswer { + saveStarted.complete(Unit) + finishSave.await() + createContactRecord(VALID_CONTACT_KEY_A) + } + val import = async { sut.importContacts(listOf(alice, bob)) } + saveStarted.await() + + sut.wipeLocalState() + finishSave.complete(Unit) + + assertTrue(import.await().isFailure) + verifyBlocking(pubkyService, never()) { saveContact(eq(VALID_CONTACT_KEY_B), any(), anyOrNull(), any(), any()) } + assertTrue(sut.contacts.value.isEmpty()) + assertFalse(sut.isImportingContacts.value) + assertNull(sut.contactImportFailure.value) + } + + @Test + fun `importContacts clears the pending import only once it succeeds`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) + whenever(pubkyService.saveContact(eq(VALID_CONTACT_KEY_A), any(), anyOrNull(), any(), any())) + .thenAnswer { throw TestAppError("Storage unavailable") } + .thenReturn(createContactRecord(VALID_CONTACT_KEY_A)) + assertTrue(sut.prepareImport().isSuccess) + assertEquals(1, sut.pendingImportContacts.value.size) + + assertTrue(sut.importContacts(sut.pendingImportContacts.value).isFailure) + assertNotNull(sut.pendingImportProfile.value) + assertEquals(1, sut.pendingImportContacts.value.size) + assertEquals(0L, sut.contactImportVersion.value) + + assertTrue(sut.importContacts(sut.pendingImportContacts.value).isSuccess) + + assertNull(sut.pendingImportProfile.value) + assertTrue(sut.pendingImportContacts.value.isEmpty()) + assertEquals(listOf(VALID_CONTACT_KEY_A), sut.contacts.value.map { it.publicKey }) + assertEquals(1L, sut.contactImportVersion.value) + } + + @Test + fun `leaving during a failing import keeps the pending import and reports the failure`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) + val saveStarted = CompletableDeferred() + val failSave = CompletableDeferred() + whenever(pubkyService.saveContact(eq(VALID_CONTACT_KEY_A), any(), anyOrNull(), any(), any())) + .doSuspendableAnswer { + saveStarted.complete(Unit) + failSave.await() + throw TestAppError("Storage unavailable") + } + assertTrue(sut.prepareImport().isSuccess) + val pending = sut.pendingImportContacts.value + val screen = launch { sut.importContacts(pending) } + saveStarted.await() + + sut.discardPendingImport() + screen.cancelAndJoin() + assertEquals(pending, sut.pendingImportContacts.value) + failSave.complete(Unit) + + assertFalse(sut.isImportingContacts.value) + assertEquals("Storage unavailable", sut.contactImportFailure.value?.message) + assertEquals(0L, sut.contactImportVersion.value) + assertNotNull(sut.pendingImportProfile.value) + assertEquals(pending, sut.pendingImportContacts.value) + assertTrue(sut.contacts.value.isEmpty()) + + sut.clearContactImportFailure() + sut.discardPendingImport() + assertNull(sut.contactImportFailure.value) + assertNull(sut.pendingImportProfile.value) + assertTrue(sut.pendingImportContacts.value.isEmpty()) + } + + @Test + fun `loadContacts publishes saved records before their profile lookups finish`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn( + listOf( + createContactRecord(VALID_CONTACT_KEY_A, label = "Saved label"), + createContactRecord(VALID_CONTACT_KEY_B, profile = createPaykitProfile("Bob")), + ), + ) + val lookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { lookup.await() } + val loadVersion = sut.contactsLoadVersion.value + val completionVersion = sut.contactsLoadCompletionVersion.value + + sut.loadContacts() + + assertEquals(listOf("Bob", "Saved label"), sut.contacts.value.map { it.name }) + assertFalse(sut.isLoadingContacts.value) + assertEquals(loadVersion + 1, sut.contactsLoadVersion.value) + assertEquals(completionVersion + 1, sut.contactsLoadCompletionVersion.value) + + lookup.complete(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice"))) + + assertEquals(listOf("Alice", "Bob"), sut.contacts.value.map { it.name }) + assertEquals(listOf(VALID_CONTACT_KEY_A, VALID_CONTACT_KEY_B), sut.contacts.value.map { it.publicKey }) + assertEquals(loadVersion + 1, sut.contactsLoadVersion.value) + assertEquals(completionVersion + 1, sut.contactsLoadCompletionVersion.value) + verify(pubkyService, never()).resolveContactProfile(eq(VALID_CONTACT_KEY_B), any(), any(), anyOrNull()) + } + + @Test + fun `loadContacts shows a session profile without a lookup until it is stale, then while it refreshes`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) + stubFollowLookup(VALID_CONTACT_KEY_A, "Alice") + assertTrue(sut.prepareImport().isSuccess) + val lookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { lookup.await() } + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A))) + advanceTimeBy(PubkyRepo.CONTACT_PROFILE_FRESHNESS - 1.milliseconds) + + sut.loadContacts() + + assertEquals(listOf("Alice"), sut.contacts.value.map { it.name }) + verify(pubkyService, never()).resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk) + advanceTimeBy(1.milliseconds) + + sut.loadContacts() + + assertEquals(listOf("Alice"), sut.contacts.value.map { it.name }) + lookup.complete(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice Renamed"))) + assertEquals(listOf("Alice Renamed"), sut.contacts.value.map { it.name }) + verify(pubkyService, times(1)).resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk) + } + + @Test + fun `loadContacts looks up only contacts without a profile resolved within the freshness window`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn( + listOf( + createContactRecord(VALID_CONTACT_KEY_A, "Saved A"), + createContactRecord(VALID_CONTACT_KEY_B, "Saved B"), + ), + ) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .thenReturn(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice"))) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true, PaykitReadLane.Bulk)) + .thenAnswer { throw TestAppError("offline") } + sut.loadContacts() + advanceTimeBy(PubkyRepo.CONTACT_PROFILE_FRESHNESS - 1.milliseconds) + + sut.loadContacts() + + assertEquals(listOf("Alice", "Saved B"), sut.contacts.value.map { it.name }) + verify(pubkyService, times(1)).resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk) + verify(pubkyService, times(2)).resolveContactProfile(VALID_CONTACT_KEY_B, true, PaykitReadLane.Bulk) + } + + @Test + fun `sign out forgets which contact profiles are fresh`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .thenReturn(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice"))) + sut.loadContacts() + assertEquals(listOf("Alice"), sut.contacts.value.map { it.name }) + + assertTrue(sut.signOut().isSuccess) + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { awaitCancellation() } + sut.loadContacts() + + assertEquals(listOf("Saved"), sut.contacts.value.map { it.name }) + verify(pubkyService, times(2)).resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk) + } + + @Test + fun `sign out drops session contact profiles and stops their refresh`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) + stubFollowLookup(VALID_CONTACT_KEY_A, "Alice") + assertTrue(sut.prepareImport().isSuccess) + val lookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { lookup.await() } + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A))) + advanceTimeBy(PubkyRepo.CONTACT_PROFILE_FRESHNESS) + sut.loadContacts() + verify(pubkyService).resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk) + + sut.wipeLocalState() + lookup.complete(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice"))) + assertTrue(sut.contacts.value.isEmpty()) + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A))) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { awaitCancellation() } + sut.loadContacts() + + assertEquals(listOf(PubkyProfile.placeholder(VALID_CONTACT_KEY_A).name), sut.contacts.value.map { it.name }) + } + + @Test + fun `a contact profile refresh leaves an edited or removed contact alone`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn( + listOf(createContactRecord(VALID_CONTACT_KEY_A), createContactRecord(VALID_CONTACT_KEY_B)), + ) + val lookups = listOf(VALID_CONTACT_KEY_A, VALID_CONTACT_KEY_B).associateWith { CompletableDeferred() } + lookups.forEach { (key, gate) -> + whenever(pubkyService.resolveContactProfile(key, true, PaykitReadLane.Bulk)).doSuspendableAnswer { + gate.await() + createResolution(key, paykitProfile = createPaykitProfile("Resolved")) + } + } + sut.loadContacts() + + assertTrue(sut.updateContact(VALID_CONTACT_KEY_A, "Edited", "", null, emptyList(), emptyList()).isSuccess) + assertTrue(sut.removeContact(VALID_CONTACT_KEY_B).isSuccess) + lookups.values.forEach { it.complete(Unit) } + + assertEquals(listOf("Edited"), sut.contacts.value.map { it.name }) + } + + @Test + fun `resolvePendingContactProfile looks up a label-only contact once on the interactive lane`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { awaitCancellation() } + val lookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) + .doSuspendableAnswer { lookup.await() } + sut.loadContacts() + + val callers = List(2) { async { sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) } } + val alice = createPaykitProfile("Alice", bio = "Hello", image = "pubky://a") + lookup.complete(createResolution(VALID_CONTACT_KEY_A, paykitProfile = alice)) + callers.awaitAll() + sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) + + assertEquals( + listOf(Triple("Alice", "Hello", "pubky://a")), + sut.contacts.value.map { Triple(it.name, it.bio, it.imageUrl) }, + ) + verify(pubkyService, times(1)).resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive) + } + + @Test + fun `resolvePendingContactProfile leaves a contact showing a profile alone`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.getContacts(VALID_SELF_KEY)).thenReturn(listOf(VALID_CONTACT_KEY_A)) + stubFollowLookup(VALID_CONTACT_KEY_A, "Alice") + assertTrue(sut.prepareImport().isSuccess) + clearInvocations(pubkyService) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { awaitCancellation() } + whenever(pubkyService.contactRecords()).thenReturn( + listOf( + createContactRecord(VALID_CONTACT_KEY_A), + createContactRecord(VALID_CONTACT_KEY_B, profile = createPaykitProfile("Bob")), + ), + ) + sut.loadContacts() + + sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) + sut.resolvePendingContactProfile(VALID_CONTACT_KEY_B) + + assertEquals(listOf("Alice", "Bob"), sut.contacts.value.map { it.name }) + listOf(VALID_CONTACT_KEY_A, VALID_CONTACT_KEY_B).forEach { + verify(pubkyService, never()).resolveContactProfile(it, true, PaykitReadLane.Interactive) + } + } + + @Test + fun `a failed screen lookup does not wait for the background lookup still in flight`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn( + listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"), createContactRecord(VALID_CONTACT_KEY_B)), + ) + var queuedLookup: Job? = null + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { + queuedLookup = currentCoroutineContext().job + awaitCancellation() + } + val otherLookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { otherLookup.await() } + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) + .doSuspendableAnswer { throw TestAppError("offline") } + sut.loadContacts() + + val resolve = async { sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) } + + assertTrue(resolve.isCompleted) + assertEquals(true, queuedLookup?.isCancelled) + assertTrue(sut.updateContact(VALID_CONTACT_KEY_A, "Saved", "", null, emptyList(), listOf("Friend")).isSuccess) + otherLookup.complete(createResolution(VALID_CONTACT_KEY_B, paykitProfile = createPaykitProfile("Bob"))) + assertEquals( + listOf("Bob" to emptyList(), "Saved" to listOf("Friend")), + sut.contacts.value.map { it.name to it.tags }, + ) + } + + @Test + fun `a background lookup taken over by a screen lookup applies no result`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) + val backgroundLookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { withContext(NonCancellable) { backgroundLookup.await() } } + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) + .doSuspendableAnswer { throw TestAppError("offline") } + sut.loadContacts() + sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) + + val alice = createPaykitProfile("Alice", bio = "Hello", image = "pubky://a") + backgroundLookup.complete(createResolution(VALID_CONTACT_KEY_A, paykitProfile = alice)) + + assertEquals(listOf(PubkyProfile.forDisplay(VALID_CONTACT_KEY_A, "Saved", imageUrl = null)), sut.contacts.value) + } + + @Test + fun `resolvePendingContactProfile leaves a contact whose background lookup finished alone`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn( + listOf(createContactRecord(VALID_CONTACT_KEY_A, "Alice"), createContactRecord(VALID_CONTACT_KEY_B, "Bob")), + ) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { throw TestAppError("offline") } + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true, PaykitReadLane.Bulk)).thenReturn(null) + sut.loadContacts() + + sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) + sut.resolvePendingContactProfile(VALID_CONTACT_KEY_B) + + assertEquals(listOf("Alice", "Bob"), sut.contacts.value.map { it.name }) + listOf(VALID_CONTACT_KEY_A, VALID_CONTACT_KEY_B).forEach { + verify(pubkyService, never()).resolveContactProfile(it, true, PaykitReadLane.Interactive) + } + } + + @Test + fun `a screen lookup overtaken by a sign-out applies nothing`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { awaitCancellation() } + val lookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) + .doSuspendableAnswer { withContext(NonCancellable) { lookup.await() } } + sut.loadContacts() + val resolve = async { sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) } + + sut.wipeLocalState() + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) + sut.loadContacts() + lookup.complete(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice"))) + resolve.await() + + assertEquals(listOf("Saved"), sut.contacts.value.map { it.name }) + } + + @Test + fun `a screen lookup outlives a refresh that a later contact load replaces`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"))) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { awaitCancellation() } + val lookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive)) + .doSuspendableAnswer { lookup.await() } + sut.loadContacts() + val resolve = async { sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) } + + whenever(pubkyService.contactRecords()).thenReturn( + listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"), createContactRecord(VALID_CONTACT_KEY_B, "Bob")), + ) + sut.loadContacts() + val joined = async { sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) } + + assertFalse(resolve.isCompleted) + assertFalse(joined.isCompleted) + lookup.complete(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice", bio = "Hi"))) + listOf(resolve, joined).awaitAll() + assertEquals(listOf("Alice" to "Hi", "Bob" to ""), sut.contacts.value.map { it.name to it.bio }) + verify(pubkyService, times(1)).resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive) + } + + @Test + fun `a background refresh updates the contact list in sorted batches and applies the last one when it ends`() = + test { + authenticateForTesting() + val keys = List(BATCHED_CONTACTS) { "pubkybatched-contact-$it" } + val names = keys.indices.map { "Friend ${'A' + BATCHED_CONTACTS - it}" } + whenever(pubkyService.contactRecords()).thenReturn(keys.map { createContactRecord(it, "Saved $it") }) + keys.forEachIndexed { index, key -> + whenever(pubkyService.resolveContactProfile(key, true, PaykitReadLane.Bulk)).doSuspendableAnswer { + delay(BATCHED_LOOKUP_INTERVAL * index) + createResolution(key, paykitProfile = createPaykitProfile(names[index])) + } + } + sut.loadContacts() + val updates = mutableListOf>() + backgroundScope.launch { sut.contacts.collect { list -> updates += list.map { it.name } } } + val start = currentTime + + advanceUntilIdle() + + assertEquals(BATCHED_LOOKUP_INTERVAL.inWholeMilliseconds * (BATCHED_CONTACTS - 1), currentTime - start) + val refreshUpdates = updates.drop(1) + assertTrue(refreshUpdates.size in 1..BATCHED_CONTACTS / 4, "updates: ${refreshUpdates.size}") + assertTrue(refreshUpdates.all { it == it.sortedBy { name -> name.lowercase() } }) + assertEquals(names.sortedBy { it.lowercase() }, sut.contacts.value.map { it.name }) + } + + @Test + fun `a refresh batch overtaken by a sign-out or an identity change is dropped`() = test { + listOf("sign-out", "identity change").forEachCase({ it }) { case -> + resetForCase() + authenticateForTesting() + val records = listOf( + createContactRecord(VALID_CONTACT_KEY_A, "Saved A"), + createContactRecord(VALID_CONTACT_KEY_B, "Saved B"), + ) + whenever(pubkyService.contactRecords()).thenReturn(records) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .thenReturn(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice"))) + .doSuspendableAnswer { awaitCancellation() } + val slowLookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { + withContext(NonCancellable) { slowLookup.await() } + null + } + sut.loadContacts() + assertEquals(listOf("Saved A", "Saved B"), sut.contacts.value.map { it.name }, case) + + if (case == "sign-out") { + sut.wipeLocalState() + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn(records) + sut.loadContacts() + } else { + val ringPubky = stubRingCredential() + whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) + assertTrue(sut.adoptRingIdentity(ringPubky).isSuccess, case) + } + advanceTimeBy(PubkyRepo.CONTACT_REFRESH_BATCH_WINDOW) + runCurrent() + + assertEquals(listOf("Saved A", "Saved B"), sut.contacts.value.map { it.name }, case) + slowLookup.complete(Unit) + } + } + + @Test + fun `resolvePendingContactProfile applies a profile the refresh found but has not applied yet`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn( + listOf(createContactRecord(VALID_CONTACT_KEY_A, "Saved"), createContactRecord(VALID_CONTACT_KEY_B, "Bob")), + ) + val alice = createPaykitProfile("Alice", bio = "Hello", image = "pubky://a") + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .thenReturn(createResolution(VALID_CONTACT_KEY_A, paykitProfile = alice)) + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_B, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { awaitCancellation() } + sut.loadContacts() + assertEquals(listOf("Bob", "Saved"), sut.contacts.value.map { it.name }) + + sut.resolvePendingContactProfile(VALID_CONTACT_KEY_A) + + assertEquals( + listOf(Triple("Alice", "Hello", "pubky://a"), Triple("Bob", "", null)), + sut.contacts.value.map { Triple(it.name, it.bio, it.imageUrl) }, + ) + verify(pubkyService, never()).resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Interactive) + } + + @Test + fun `repeated contact loads share one background profile refresh`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn(listOf(createContactRecord(VALID_CONTACT_KEY_A))) + val lookup = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk)) + .doSuspendableAnswer { lookup.await() } + + sut.loadContacts() + sut.loadContacts() + lookup.complete(createResolution(VALID_CONTACT_KEY_A, paykitProfile = createPaykitProfile("Alice"))) + + assertEquals(listOf("Alice"), sut.contacts.value.map { it.name }) + verify(pubkyService, times(1)).resolveContactProfile(VALID_CONTACT_KEY_A, true, PaykitReadLane.Bulk) + } + @Test fun `loadContacts should treat empty SDK contact records as empty`() = test { authenticateForTesting() @@ -2531,8 +3699,7 @@ class PubkyRepoTest : BaseUnitTest() { val publicKey = checkNotNull(sut.publicKey.value) val pendingContactKey = "pubkypending-contact" whenever(pubkyService.getContacts(publicKey)).thenReturn(listOf(pendingContactKey)) - whenever(pubkyService.resolveContactProfile(pendingContactKey, true)) - .thenReturn(createResolution(pendingContactKey, paykitProfile = createPaykitProfile("Pending Contact"))) + stubFollowLookup(pendingContactKey, "Pending Contact") sut.prepareImport() assertNotNull(sut.pendingImportProfile.value) @@ -2577,7 +3744,7 @@ class PubkyRepoTest : BaseUnitTest() { val contactKey = "pubkyabc123" whenever(pubkyService.contactRecords()) .thenReturn(listOf(createContactRecord(contactKey, label = "Extracted"))) - whenever(pubkyService.resolveContactProfile(contactKey, true)).thenReturn(null) + whenever(pubkyService.resolveContactProfile(contactKey, true, PaykitReadLane.Bulk)).thenReturn(null) sut.loadContacts() @@ -2623,6 +3790,56 @@ class PubkyRepoTest : BaseUnitTest() { sut.initialize() } + private fun stubSavedSessionRestore(): SavedSessionRestore { + val restore = SavedSessionRestore() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + whenever { pubkyService.importSession("saved_session") }.doSuspendableAnswer { restore.answer() } + return restore + } + + private fun resetForCase() { + reset(pubkyService, keychain, sharedPubkyClient, imageLoader, pubkyStore, settingsStore) + profileSetupPending.value = false + setUp() + } + + private suspend fun saveNewProfile() = + sut.saveProfile(name = "New", bio = "", links = emptyList(), tags = emptyList(), imageUrl = null) + + private fun assertProfileOutcome(case: ProfileOvertake, data: PubkyStoreData) { + assertEquals(case.profileName, sut.profile.value?.name, case.name) + assertEquals(case.profileName, data.cachedName, case.name) + assertEquals(case.owner, data.cachedProfileOwner, case.name) + assertEquals(case.owner, data.ownerPublicKey, case.name) + } + + private fun stubGatedPubkyStore(): GatedPubkyStore { + val store = GatedPubkyStore() + whenever { pubkyStore.update(any()) }.doSuspendableAnswer { + val isGated = store.gateNextUpdate + if (isGated) { + store.gateNextUpdate = false + store.gatedUpdateStarted.complete(Unit) + store.releaseGatedUpdate.await() + } + store.data = it.getArgument<(PubkyStoreData) -> PubkyStoreData>(0)(store.data) + if (isGated) store.gatedUpdateApplied.complete(Unit) + Unit + } + whenever { pubkyStore.reset() }.thenAnswer { + store.data = PubkyStoreData() + Unit + } + return store + } + + private fun gateNextProfileLoadStoreWrite(store: GatedPubkyStore, loadedName: String) { + whenever { pubkyService.resolveContactProfile(VALID_SELF_KEY, true) }.doSuspendableAnswer { + store.gateNextUpdate = true + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = loadedName)) + } + } + private fun identityHttpClient() = HttpClient( MockEngine { respond( @@ -2720,9 +3937,55 @@ class PubkyRepoTest : BaseUnitTest() { pubkyProfile = pubkyProfile, fetchedAt = "2026-01-01T00:00:00Z", ) + + private fun stubFollowLookup(publicKey: String, name: String) { + whenever { + pubkyService.resolveContactProfile( + publicKey = publicKey, + allowPubkyProfileFallback = true, + lane = PaykitReadLane.Interactive, + timeout = PubkyRepo.IMPORT_FOLLOW_LOOKUP_TIMEOUT, + ) + }.thenReturn(createResolution(publicKey, paykitProfile = createPaykitProfile(name))) + } + + private fun delegateProfileReadsTo(paykit: PaykitSdkService) { + whenever { pubkyService.resolveContactProfile(any(), any(), any(), anyOrNull()) }.doSuspendableAnswer { + paykit.resolveContactProfile(it.getArgument(0), it.getArgument(1), it.getArgument(2), it.getArgument(3)) + } + } } private class TestAppError(message: String) : AppError(message) +private class SavedSessionRestore { + var answer: suspend () -> String = { throw TestAppError("Offline") } +} + +private class ProfileOvertake( + val name: String, + val profileName: String?, + val owner: String?, + val resetReleasesWrite: Boolean = false, + val overtake: suspend () -> Result, +) + +private class RingHandoff( + val name: String, + val profileName: String?, + val remoteName: String?, + val adopted: Boolean, + val resolves: VerificationMode, + val handoff: (String) -> PubkyProfile?, +) + +private class GatedPubkyStore { + var data = PubkyStoreData() + var gateNextUpdate = false + val gatedUpdateStarted = CompletableDeferred() + val releaseGatedUpdate = CompletableDeferred() + val gatedUpdateApplied = CompletableDeferred() +} + private fun String.ensurePubkyPrefixForTest(): String = if (startsWith("pubky")) this else "pubky$this" diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt index ecb8df5030..809f88ab53 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkOperationLockTest.kt @@ -78,6 +78,38 @@ class PaykitSdkOperationLockTest { assertTrue(ran) } + @Test + fun `unlocked work skips the lock but not wipe admission`() = runTest { + val lock = PaykitSdkOperationLock() + val releaseActive = CompletableDeferred() + val active = launch { lock.withLock { releaseActive.await() } } + runCurrent() + assertEquals("unlocked", lock.withoutLock { "unlocked" }) + + val releaseOvertaken = CompletableDeferred() + val overtaken = async { + assertFailsWith { lock.withoutLock { releaseOvertaken.await() } } + } + runCurrent() + val releaseWipe = CompletableDeferred() + val wipe = launch { + lock.withWalletWipe { + assertEquals("owner", lock.withoutLock { "owner" }) + releaseWipe.await() + } + } + runCurrent() + assertFailsWith { lock.withoutLock { error("unlocked work ran during wipe") } } + releaseOvertaken.complete(Unit) + assertEquals("wallet_wipe_in_progress", overtaken.await().code) + + releaseActive.complete(Unit) + releaseWipe.complete(Unit) + active.join() + wipe.join() + assertEquals("fresh", lock.withoutLock { "fresh" }) + } + @Test fun `cancelled queued operation never executes`() = runTest { val lock = PaykitSdkOperationLock() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index efc08ce571..3551b3980d 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -1,5 +1,6 @@ package to.bitkit.services +import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope @@ -7,6 +8,8 @@ import com.synonym.paykit.IdentityStatus import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState import com.synonym.paykit.PaykitException +import com.synonym.paykit.PaykitReceiverCapabilities +import com.synonym.paykit.PaykitReceiverMarker import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole @@ -24,18 +27,27 @@ import com.synonym.paykit.ReceiverNoiseSecretKey import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.description import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.PubkyStore @@ -48,18 +60,27 @@ import to.bitkit.ext.toHex import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyProfileData import to.bitkit.repositories.PubkyContactError +import to.bitkit.test.forEachCase import to.bitkit.utils.AppError import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass") class PaykitSdkServiceTest { companion object { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val FILE_URI = "pubky://$RING_PUBKY/pub/pubky.app/files/avatar" + private val READ_TIMEOUT = 10.seconds } @Test @@ -354,6 +375,386 @@ class PaykitSdkServiceTest { } } + @Test + fun `public reads and locked operations do not wait for each other`() = runTest { + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + whenever(sdk.contactRecords()).thenReturn(emptyList()) + service.contactRecords() + val readGate = CompletableDeferred() + whenever(sdk.resolveContactProfile(RING_PUBKY, PaykitReceiverPaths.WALLET, true)) + .doSuspendableAnswer { readGate.await() } + + val read = async { service.resolveContactProfile(RING_PUBKY, allowPubkyProfileFallback = true) } + runCurrent() + assertEquals(emptyList(), service.contactRecords()) + assertFalse(read.isCompleted) + readGate.complete(null) + assertNull(read.await()) + + val lockedGate = CompletableDeferred>() + whenever(sdk.contactRecords()).doSuspendableAnswer { lockedGate.await() } + whenever(sdk.fetchPubkyFollows(RING_PUBKY)).thenReturn(listOf("follow")) + val locked = async { service.contactRecords() } + runCurrent() + assertEquals(listOf("follow"), service.fetchPubkyFollows(RING_PUBKY)) + assertFalse(locked.isCompleted) + lockedGate.complete(emptyList()) + assertEquals(emptyList(), locked.await()) + } + + @Test + fun `public reads run at most six at a time`() = runTest { + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + whenever(sdk.contactRecords()).thenReturn(emptyList()) + service.contactRecords() + val gate = CompletableDeferred() + var active = 0 + var maxActive = 0 + whenever(sdk.fetchPubkyFileBounded(FILE_URI, 1uL)).doSuspendableAnswer { + maxActive = maxOf(maxActive, ++active) + gate.await() + active-- + byteArrayOf(1) + } + + val reads = List(10) { async { service.fetchFile(FILE_URI, 1uL) } } + runCurrent() + assertEquals(6, active) + gate.complete(Unit) + + reads.awaitAll().forEach { assertContentEquals(byteArrayOf(1), it) } + assertEquals(6, maxActive) + verify(sdk, times(10)).fetchPubkyFileBounded(FILE_URI, 1uL) + } + + @Test + fun `public reads started without an sdk instance wait for the operation lock then run concurrently`() = runTest { + val keychain = mock() + val lockedGate = CompletableDeferred() + whenever(keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)).doSuspendableAnswer { lockedGate.await() } + val sdk = mock() + val readGate = CompletableDeferred() + var active = 0 + whenever(sdk.fetchPubkyFollows(RING_PUBKY)).doSuspendableAnswer { + active++ + readGate.await() + listOf("follow") + } + var handlesCreated = 0 + val service = PaykitSdkService(mock(), keychain, mock()) { + handlesCreated++ + sdk + } + + val locked = async { service.clearState() } + runCurrent() + val reads = List(3) { async { service.fetchPubkyFollows(RING_PUBKY) } } + runCurrent() + assertEquals(0, active) + assertTrue(reads.none { it.isCompleted }) + assertEquals(0, handlesCreated) + + lockedGate.complete(Unit) + runCurrent() + assertEquals(3, active) + assertEquals(1, handlesCreated) + readGate.complete(Unit) + reads.awaitAll().forEach { assertEquals(listOf("follow"), it) } + locked.await() + } + + @Test + fun `receiver reads run outside the operation lock with their filtering intact`() = runTest { + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + whenever(sdk.contactRecords()).thenReturn(emptyList()) + service.contactRecords() + whenever(sdk.paykitReceiverPaths(RING_PUBKY)) + .thenReturn(listOf(PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER, "other/path")) + whenever(sdk.paykitReceiverMarker(RING_PUBKY, PaykitReceiverPaths.WALLET)) + .thenReturn(receiverMarker(PaykitReceiverPaths.WALLET, paymentRequests = true, outgoingPayments = true)) + whenever(sdk.paykitReceiverMarker(RING_PUBKY, PaykitReceiverPaths.SERVER)) + .thenReturn(receiverMarker(PaykitReceiverPaths.SERVER, paymentRequests = false, outgoingPayments = false)) + val lockedGate = CompletableDeferred>() + whenever(sdk.contactRecords()).doSuspendableAnswer { lockedGate.await() } + val locked = async { service.contactRecords() } + runCurrent() + + assertEquals( + listOf(PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), + service.discoverRelevantReceiverPaths(RING_PUBKY, PaykitReadLane.Bulk), + ) + assertEquals( + listOf(PaykitReceiverPaths.WALLET), + service.paymentRequestReceiverPaths(RING_PUBKY, PaykitReadLane.Bulk), + ) + val selection = + service.privateReceiverPathSelection(RING_PUBKY, listOf(PaykitReceiverPaths.SERVER), PaykitReadLane.Bulk) + assertEquals(listOf(PaykitReceiverPaths.WALLET, PaykitReceiverPaths.SERVER), selection.linkableReceiverPaths) + assertEquals(listOf(PaykitReceiverPaths.WALLET), selection.publishableReceiverPaths) + assertEquals(emptyList(), selection.cleanupProtectedReceiverPaths) + assertNull(selection.error) + assertFalse(locked.isCompleted) + + lockedGate.complete(emptyList()) + assertEquals(emptyList(), locked.await()) + } + + @Test + fun `private receiver selection protects a path whose marker read fails`() = runTest { + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val failure = AppError("Marker unavailable") + whenever(sdk.paykitReceiverMarker(RING_PUBKY, PaykitReceiverPaths.WALLET)) + .thenReturn(receiverMarker(PaykitReceiverPaths.WALLET, paymentRequests = true, outgoingPayments = true)) + whenever(sdk.paykitReceiverMarker(RING_PUBKY, PaykitReceiverPaths.SERVER)).thenAnswer { throw failure } + + val selection = + service.privateReceiverPathSelection(RING_PUBKY, listOf(PaykitReceiverPaths.SERVER), PaykitReadLane.Bulk) + + assertEquals(listOf(PaykitReceiverPaths.WALLET), selection.linkableReceiverPaths) + assertEquals(listOf(PaykitReceiverPaths.WALLET), selection.publishableReceiverPaths) + assertEquals(listOf(PaykitReceiverPaths.SERVER), selection.cleanupProtectedReceiverPaths) + assertSame(failure, selection.error) + } + + @Test + fun `bulk reads leave two read permits to interactive reads`() = runTest { + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + whenever(sdk.contactRecords()).thenReturn(emptyList()) + service.contactRecords() + val gate = CompletableDeferred() + var bulkActive = 0 + var interactiveActive = 0 + whenever(sdk.resolveContactProfile(any(), any(), any())).doSuspendableAnswer { + bulkActive++ + gate.await() + bulkActive-- + null + } + whenever(sdk.fetchPubkyFileBounded(FILE_URI, 1uL)).doSuspendableAnswer { + interactiveActive++ + gate.await() + interactiveActive-- + byteArrayOf(1) + } + + val bulkReads = List(10) { + async { service.resolveContactProfile("$RING_PUBKY$it", true, PaykitReadLane.Bulk) } + } + runCurrent() + assertEquals(4, bulkActive) + + val interactiveReads = List(3) { async { service.fetchFile(FILE_URI, 1uL) } } + runCurrent() + assertEquals(4, bulkActive) + assertEquals(2, interactiveActive) + + gate.complete(Unit) + bulkReads.awaitAll() + interactiveReads.awaitAll() + verify(sdk, times(10)).resolveContactProfile(any(), any(), any()) + verify(sdk, times(3)).fetchPubkyFileBounded(FILE_URI, 1uL) + } + + @Test + fun `bulk reads start in request order and a cancelled one frees both permits`() = runTest { + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + whenever(sdk.contactRecords()).thenReturn(emptyList()) + service.contactRecords() + val started = mutableListOf() + val gates = List(6) { CompletableDeferred() } + whenever(sdk.paykitReceiverPaths(any())).doSuspendableAnswer { + val key = it.getArgument(0) + started += key + gates[key.removePrefix(RING_PUBKY).toInt()].await() + emptyList() + } + val reads = List(6) { + async { service.discoverRelevantReceiverPaths("$RING_PUBKY$it", PaykitReadLane.Bulk) } + } + runCurrent() + assertEquals(List(4) { "$RING_PUBKY$it" }, started) + + reads[1].cancel() + runCurrent() + assertEquals(List(5) { "$RING_PUBKY$it" }, started) + + val interactiveGate = CompletableDeferred() + var interactiveActive = 0 + whenever(sdk.fetchPubkyFileBounded(FILE_URI, 1uL)).doSuspendableAnswer { + interactiveActive++ + interactiveGate.await() + } + val interactiveReads = List(2) { async { service.fetchFile(FILE_URI, 1uL) } } + runCurrent() + assertEquals(2, interactiveActive) + + gates[0].complete(Unit) + runCurrent() + assertEquals(List(6) { "$RING_PUBKY$it" }, started) + + gates.forEach { it.complete(Unit) } + interactiveGate.complete(byteArrayOf(1)) + reads.filterIndexed { index, _ -> index != 1 }.awaitAll() + interactiveReads.awaitAll() + } + + @Test + fun `a freed read slot goes to a waiting interactive read before bulk reads queued for a slot`() = runTest { + val interactive = List(7) { "$FILE_URI$it" } + val bulk = List(4) { "$RING_PUBKY$it" } + val gates = (interactive + bulk).associateWith { CompletableDeferred() } + val started = mutableListOf() + val service = gatedReadService(gates, started) + + val reads = interactive.take(6).map { async { service.fetchFile(it, 1uL) } } + + bulk.map { async { service.resolveContactProfile(it, true, PaykitReadLane.Bulk) } } + runCurrent() + val lateRead = async { service.fetchFile(interactive[6], 1uL) } + runCurrent() + assertEquals(interactive.take(6), started) + + gates.getValue(interactive[0]).complete(Unit) + runCurrent() + assertEquals(interactive, started) + + gates.getValue(interactive[1]).complete(Unit) + runCurrent() + assertEquals(interactive + bulk.first(), started) + + gates.values.forEach { it.complete(Unit) } + (reads + lateRead).awaitAll() + assertEquals(interactive + bulk, started) + } + + @Test + fun `a cancelled read slot waiter takes no slot and passes on one it was handed`() = runTest { + listOf("cancelled while queued" to false, "cancelled once handed a slot" to true) + .forEachCase({ it.first }) { (case, handed) -> + val interactive = List(7) { "$FILE_URI$it" } + val bulk = RING_PUBKY + val gates = (interactive + bulk).associateWith { CompletableDeferred() } + val started = mutableListOf() + val service = gatedReadService(gates, started) + val reads = interactive.take(6).map { async { service.fetchFile(it, 1uL) } } + + async { service.resolveContactProfile(bulk, true, PaykitReadLane.Bulk) } + runCurrent() + val lateRead = async { service.fetchFile(interactive[6], 1uL) } + runCurrent() + + if (handed) { + reads.first().invokeOnCompletion { lateRead.cancel() } + } else { + lateRead.cancel() + runCurrent() + assertEquals(interactive.take(6), started, case) + } + gates.getValue(interactive[0]).complete(Unit) + runCurrent() + + assertTrue(lateRead.isCancelled, case) + assertEquals(interactive.take(6) + bulk, started, case) + gates.values.forEach { it.complete(Unit) } + reads.awaitAll() + } + } + + @Test + fun `a read timeout counts only the time the read holds its slot and fails with its own error`() = runTest { + val sdk = mock() + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + val busy = List(6) { "$RING_PUBKY-busy-$it" } + val freeSlots = CompletableDeferred() + whenever(sdk.resolveContactProfile(any(), any(), any())).doSuspendableAnswer { + when (it.getArgument(0)) { + in busy -> freeSlots.await() + "$RING_PUBKY-stuck" -> awaitCancellation() + } + null + } + val busyReads = busy.map { async { service.resolveContactProfile(it, true) } } + runCurrent() + val start = currentTime + + val timedOut = async { + runCatching { service.resolveContactProfile("$RING_PUBKY-stuck", true, timeout = READ_TIMEOUT) } + } + advanceTimeBy(READ_TIMEOUT * 2) + freeSlots.complete(Unit) + busyReads.awaitAll() + advanceTimeBy(READ_TIMEOUT - 1.milliseconds) + runCurrent() + assertFalse(timedOut.isCompleted) + advanceTimeBy(1.milliseconds) + runCurrent() + + assertIs(timedOut.await().exceptionOrNull()) + assertEquals((READ_TIMEOUT * 3).inWholeMilliseconds, currentTime - start) + assertNull(service.resolveContactProfile("$RING_PUBKY-null", true, timeout = READ_TIMEOUT)) + } + + @Test + fun `activation returns while identity publication runs and approval republish joins it until the cap`() = runTest { + listOf("publication finishes" to true, "cap" to false).forEachCase({ it.first }) { (case, gateOpens) -> + val keychain = mock() + stubReceiverNoiseSecret(keychain) + val store = mock() + whenever(store.data).thenReturn(flowOf(PubkyStoreData())) + val publicationGate = CompletableDeferred() + var published = false + val bootstrap = mock() + whenever(bootstrap.republishIdentity(any())).doSuspendableAnswer { + publicationGate.await().also { published = true } + } + val access = mock() + val noise = mock() + whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) + whenever(access.exportSessionSecret()).thenReturn("new-session") + whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + val sdk = mock() + val service = PaykitSdkService( + context = mock(), + keychain = keychain, + pubkyStore = store, + bootstrapFactory = { bootstrap }, + ioDispatcher = StandardTestDispatcher(testScheduler), + sdkFactory = { sdk }, + ) + val activationStart = currentTime + + val activation = async { + service.activateRegisteredIdentity(PubkySessionBootstrapResult(access, "pubky$RING_PUBKY")) + } + runCurrent() + + assertTrue(activation.isCompleted, case) + assertEquals(activationStart, currentTime, case) + verify(sdk, description(case)).initialize() + verify(bootstrap, description(case)).republishIdentity("pubky$RING_PUBKY") + assertFalse(published, case) + val start = currentTime + + val approval = async { service.republishIdentityIfNeeded(RING_PUBKY) } + advanceTimeBy(4_999) + runCurrent() + assertFalse(approval.isCompleted, case) + if (gateOpens) publicationGate.complete(true) else advanceTimeBy(1) + runCurrent() + + assertTrue(approval.isCompleted, case) + assertEquals(start + if (gateOpens) 4_999 else 5_000, currentTime, case) + verify(bootstrap, description(case)).republishIdentity("pubky$RING_PUBKY") + publicationGate.complete(true) + runCurrent() + assertTrue(published, case) + } + } + @Test fun `only explicit readd unblocks every saved private receiver`() = runTest { for (restoreConnection in listOf(false, true)) { @@ -441,6 +842,65 @@ class PaykitSdkServiceTest { verify(sdk).blockPeer(RING_PUBKY, PaykitReceiverPaths.SERVER) } + @Test + fun `a contact save queued behind an identity change is not saved to the new identity`() = runTest { + val originalIdentity = "pubky$RING_PUBKY" + val newIdentity = "pubky5${RING_PUBKY.drop(1)}" + val contactKey = "pubky8${RING_PUBKY.drop(1)}" + val keychain = mock() + stubReceiverNoiseSecret(keychain) + val identityChangeGate = CompletableDeferred() + whenever(keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)).doSuspendableAnswer { identityChangeGate.await() } + val store = mock() + whenever(store.data).thenReturn(flowOf(PubkyStoreData())) + val bootstrap = mock() + whenever(bootstrap.republishIdentity(any())).thenReturn(true) + val access = mock() + val noise = mock() + whenever(noise.exportBytes()).thenReturn(ByteArray(32) { 1 }) + whenever(access.exportSessionSecret()).thenReturn("new-session") + whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + val originalSdk = mock() + whenever(originalSdk.identityStatus()).thenReturn(IdentityStatus(originalIdentity, true)) + val newSdk = mock() + whenever(newSdk.identityStatus()).thenReturn(IdentityStatus(newIdentity, true)) + whenever(newSdk.linkedPeers()).thenReturn(emptyList()) + whenever(newSdk.saveContact(any())).thenReturn(mock()) + val handles = ArrayDeque(listOf(originalSdk, newSdk)) + val service = PaykitSdkService( + context = mock(), + keychain = keychain, + pubkyStore = store, + bootstrapFactory = { bootstrap }, + ioDispatcher = StandardTestDispatcher(testScheduler), + sdkFactory = { handles.removeFirst() }, + ) + + val identityChange = async { + service.activateRegisteredIdentity(PubkySessionBootstrapResult(access, newIdentity)) + } + runCurrent() + val save = async { + assertFailsWith { + service.saveContact( + contactKey, + "Contact", + restorePrivateConnection = true, + expectedIdentity = originalIdentity, + ) + } + } + runCurrent() + assertFalse(save.isCompleted) + identityChangeGate.complete(Unit) + identityChange.await() + save.await() + + verify(newSdk, never()).saveContact(any()) + service.saveContact(contactKey, "Contact", restorePrivateConnection = true, expectedIdentity = newIdentity) + verify(newSdk).saveContact(any()) + } + @Test fun `blocked peer cleanup does not attempt network delivery`() = runTest { val sdk = mock() @@ -450,6 +910,41 @@ class PaykitSdkServiceTest { verify(sdk, never()).clearPrivatePaymentListAndProcessOutbound(any(), any()) } + private suspend fun gatedReadService( + gates: Map>, + started: MutableList, + ): PaykitSdkService { + val sdk = mock() + val read: suspend (String) -> Unit = { key -> + started += key + gates.getValue(key).await() + } + whenever { sdk.contactRecords() }.thenReturn(emptyList()) + gates.keys.forEach { key -> + whenever { sdk.fetchPubkyFileBounded(key, 1uL) }.doSuspendableAnswer { + read(key) + byteArrayOf(1) + } + } + whenever { sdk.resolveContactProfile(any(), any(), any()) }.doSuspendableAnswer { + read(it.getArgument(0)) + null + } + return PaykitSdkService(mock(), mock(), mock()) { sdk }.also { it.contactRecords() } + } + + private fun receiverMarker(path: String, paymentRequests: Boolean, outgoingPayments: Boolean) = + PaykitReceiverMarker( + receiverPath = path, + capabilities = PaykitReceiverCapabilities( + privatePayments = true, + paymentRequests = paymentRequests, + receipts = false, + outgoingPayments = outgoingPayments, + ), + noisePublicKey = "noise", + ) + private fun contactPeer(path: String, state: LinkedPeerState) = LinkedPeerRecord( counterparty = RING_PUBKY, counterpartyReceiverPath = path, diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index ea6ff80aa3..351095c8da 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -31,6 +31,7 @@ import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.PubkyStore @@ -200,6 +201,86 @@ class PaykitSdkServiceWipeTest { } } + @Test + fun `public read waiting to build an sdk when a wipe starts fails without building one`() = runTest { + val keychain = mock() + val releaseLocked = CompletableDeferred() + whenever(keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)).doSuspendableAnswer { releaseLocked.await() } + val sdk = mock() + whenever(sdk.fetchPubkyFollows(RING_PUBKY)).thenReturn(listOf("follow")) + var handlesCreated = 0 + val service = PaykitSdkService(mock(), keychain, mock()) { + handlesCreated++ + sdk + } + + val locked = async(start = CoroutineStart.UNDISPATCHED) { service.clearState() } + val read = async(start = CoroutineStart.UNDISPATCHED) { + assertFailsWith { service.fetchPubkyFollows(RING_PUBKY) } + } + val wipe = async(start = CoroutineStart.UNDISPATCHED) { service.withWalletWipe { handlesCreated } } + releaseLocked.complete(Unit) + + locked.await() + assertEquals("wallet_wipe_in_progress", read.await().code) + assertEquals(0, wipe.await()) + assertEquals(0, handlesCreated) + verify(sdk, never()).fetchPubkyFollows(any()) + } + + @Test + fun `public reads during a wipe are rejected except for the wipe itself`() = runTest { + val sdk = mock() + whenever(sdk.contactRecords()).thenReturn(emptyList()) + whenever(sdk.fetchPubkyFollows(RING_PUBKY)).thenReturn(listOf("follow")) + var handlesCreated = 0 + val service = PaykitSdkService(mock(), mock(), mock()) { + handlesCreated++ + sdk + } + service.contactRecords() + val cleanupStarted = CompletableDeferred() + val releaseWipe = CompletableDeferred() + val wipe = async { + service.withWalletWipe { + service.contactRecords() + assertEquals(listOf("follow"), service.fetchPubkyFollows(RING_PUBKY)) + cleanupStarted.complete(Unit) + releaseWipe.await() + } + } + cleanupStarted.await() + + val rejected = assertFailsWith { service.fetchPubkyFollows(RING_PUBKY) } + assertEquals("wallet_wipe_in_progress", rejected.code) + verify(sdk, times(1)).fetchPubkyFollows(RING_PUBKY) + assertEquals(2, handlesCreated) + + releaseWipe.complete(Unit) + wipe.await() + assertEquals(listOf("follow"), service.fetchPubkyFollows(RING_PUBKY)) + assertEquals(3, handlesCreated) + } + + @Test + fun `public read that a wipe overtakes fails without delaying the wipe`() = runTest { + val sdk = mock() + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val releaseRead = CompletableDeferred>() + whenever(sdk.fetchPubkyFollows(RING_PUBKY)).doSuspendableAnswer { releaseRead.await() } + val service = PaykitSdkService(mock(), mock(), mock()) { sdk } + service.contactRecords() + val read = async(start = CoroutineStart.UNDISPATCHED) { + assertFailsWith { service.fetchPubkyFollows(RING_PUBKY) } + } + + service.withWalletWipe {} + assertFalse(read.isCompleted) + releaseRead.complete(listOf("follow")) + + assertEquals("wallet_wipe_in_progress", read.await().code) + } + private fun stubReceiverNoiseSecret(keychain: Keychain) { val blocking = mock() whenever(keychain.accessBlocking(any())).doAnswer { diff --git a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt index 4037beda38..0a4dba4272 100644 --- a/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyIdentityRepublishTest.kt @@ -187,6 +187,30 @@ class PubkyIdentityRepublishTest { first.await() } + @Test + fun `approval republish waits for a gated publication`() = runTest { + val gate = CompletableDeferred() + val bootstrap = mock() + whenever(bootstrap.republishIdentity(any())).doSuspendableAnswer { gate.await() } + val service = PaykitSdkService( + mock(), + mock(), + mock(), + { bootstrap }, + StandardTestDispatcher(testScheduler), + ) { mock() } + + val approval = async { service.republishIdentityIfNeeded(publicKey, now = 0) } + runCurrent() + + verify(bootstrap).republishIdentity("pubky$publicKey") + assertFalse(approval.isCompleted) + gate.complete(true) + runCurrent() + assertTrue(approval.isCompleted) + assertEquals(0L, currentTime) + } + @Test fun `publication timeout releases single flight for a throttled retry`() = runTest { val bootstrap = mock() @@ -210,9 +234,10 @@ class PubkyIdentityRepublishTest { assertEquals(5_000L, currentTime) assertFalse(cancelled) service.republishIdentityIfNeeded(publicKey, now = 60_000) + assertEquals(10_000L, currentTime) verify(bootstrap).republishIdentity("pubky$publicKey") - advanceTimeBy(24_999) + advanceTimeBy(19_999) runCurrent() assertFalse(cancelled) advanceTimeBy(1) diff --git a/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt index 6aab68e7b6..56e500c832 100644 --- a/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt @@ -1,16 +1,24 @@ package to.bitkit.services import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import org.junit.Test import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.KInvocationOnMock import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.spy import org.mockito.kotlin.whenever import to.bitkit.async.ServiceQueue import to.bitkit.ext.runSuspendCatching import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import kotlin.coroutines.Continuation import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn import kotlin.test.assertEquals @@ -21,6 +29,44 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class PubkyServiceTest : BaseUnitTest() { + @Test + fun `cancelling a public read cancels the underlying call`() = test { + val paykit = mock() + val sut = PubkyService(paykit) + var started = CompletableDeferred() + var cancelled = CompletableDeferred() + val pending: suspend KInvocationOnMock.() -> Nothing = { + started.complete(Unit) + try { + delay(5.seconds) + } finally { + cancelled.complete(!currentCoroutineContext().isActive) + } + throw AppError("Read finished without cancellation") + } + whenever(paykit.resolveContactProfile("pubky-test", true)).doSuspendableAnswer(pending) + whenever(paykit.fetchFile("pubky://pubky-test/avatar", 1uL)).doSuspendableAnswer(pending) + whenever(paykit.fetchPubkyFollows("pubky-test")).doSuspendableAnswer(pending) + whenever(paykit.discoverRelevantReceiverPaths("pubky-test", PaykitReadLane.Bulk)).doSuspendableAnswer(pending) + val reads = listOf Unit>( + { sut.resolveContactProfile("pubky-test", allowPubkyProfileFallback = true) }, + { sut.fetchFile("pubky://pubky-test/avatar", 1uL) }, + { sut.getContacts("pubky-test") }, + { sut.discoverRelevantReceiverPaths("pubky-test", PaykitReadLane.Bulk) }, + ) + + for (read in reads) { + started = CompletableDeferred() + cancelled = CompletableDeferred() + val caller = launch { read() } + started.await() + + caller.cancelAndJoin() + + assertTrue(cancelled.await()) + } + } + @Test fun `relay timeout cancels the queued call and allows another approval`() = test { ServiceQueue.CORE.background { diff --git a/app/src/test/java/to/bitkit/test/ForEachCase.kt b/app/src/test/java/to/bitkit/test/ForEachCase.kt new file mode 100644 index 0000000000..d340e7557e --- /dev/null +++ b/app/src/test/java/to/bitkit/test/ForEachCase.kt @@ -0,0 +1,14 @@ +package to.bitkit.test + +/** + * Runs each case of a table test. An assertion already names its case, so it is rethrown as it is. Any other failure, + * such as an exception from the code under test or the cancellation a test timeout sends to a case stuck on a gate, + * is rethrown as an [AssertionError] that names the case. + */ +inline fun Iterable.forEachCase(name: (T) -> String, action: (T) -> Unit) = forEach { case -> + try { + action(case) + } catch (e: Throwable) { + throw if (e is AssertionError) e else AssertionError("${name(case)}: $e", e) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index 585e559f59..56a11ff325 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -198,6 +198,58 @@ class ContactDetailViewModelTest : BaseUnitTest() { assertEquals(listOf("Bitcoin"), sut.uiState.value.tags) } + @Test + fun `adding a tag to a contact still showing its label waits for its profile`() = test { + whenever(context.getString(any())).thenReturn("") + val contacts = MutableStateFlow(listOf(createContact())) + whenever(pubkyRepo.contacts).thenReturn(contacts) + val resolved = createContact().copy(name = "Alice", bio = "Hello", imageUrl = "https://example.com/a.jpg") + val lookup = CompletableDeferred() + whenever(pubkyRepo.resolvePendingContactProfile(TEST_PUBLIC_KEY)).doSuspendableAnswer { + lookup.await() + contacts.value = listOf(resolved) + } + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + sut.addTag("Bitcoin") + advanceUntilIdle() + verify(pubkyRepo, never()).updateContact(any(), any(), any(), anyOrNull(), any(), any()) + lookup.complete(Unit) + advanceUntilIdle() + + verify(pubkyRepo).updateContact( + publicKey = TEST_PUBLIC_KEY, + name = "Alice", + bio = "Hello", + imageUrl = "https://example.com/a.jpg", + links = emptyList(), + tags = listOf("Bitcoin"), + ) + } + + @Test + fun `adding a tag to a contact whose profile lookup failed saves it under its label`() = test { + whenever(context.getString(any())).thenReturn("") + val labelOnly = PubkyProfile.forDisplay(TEST_PUBLIC_KEY, "Alice", imageUrl = null) + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(labelOnly))) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + sut.showAddTagSheet() + + sut.addTag("Bitcoin") + advanceUntilIdle() + + verify(pubkyRepo).updateContact(TEST_PUBLIC_KEY, "Alice", "", null, emptyList(), listOf("Bitcoin")) + assertFalse(sut.uiState.value.showAddTagSheet) + assertEquals(listOf("Bitcoin"), sut.uiState.value.tags) + verify(pubkyRepo, times(2)).resolvePendingContactProfile(TEST_PUBLIC_KEY) + } + @Test fun `failed tag addition stays open and can be retried`() = test { whenever(context.getString(any())).thenReturn("") diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModelTest.kt index 65ef000353..644a914dab 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportOverviewViewModelTest.kt @@ -1,27 +1,36 @@ package to.bitkit.ui.screens.contacts -import android.content.Context +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset 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 to.bitkit.test.forEachCase +import to.bitkit.utils.AppError 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() + private val isImportingContacts = MutableStateFlow(false) + private val contactImportVersion = MutableStateFlow(0L) + private val pendingImportProfile = MutableStateFlow(null) + private val pendingImportContacts = MutableStateFlow>(emptyList()) @Test fun `pending import blocks duplicate requests and clears progress after cancellation`() = test { @@ -54,27 +63,126 @@ class ContactImportOverviewViewModelTest : BaseUnitTest() { } @Test - fun `importAll clears pending import and completes`() = test { + fun `importAll completes once the import succeeds`() = test { val contacts = listOf(createProfile(publicKey = "pubkyalice"), createProfile(publicKey = "pubkybob")) stubPendingImport(profile = createProfile(publicKey = "pubkyself"), contacts = contacts) whenever(pubkyRepo.importContacts(contacts)).thenReturn(Result.success(Unit)) val sut = createSut() + advanceUntilIdle() + assertFalse(sut.uiState.value.shouldRedirectToPayContacts) + + sut.importAll() + advanceUntilIdle() + + verify(pubkyRepo).importContacts(contacts) + assertTrue(sut.uiState.value.shouldRedirectToPayContacts) + } + + @Test + fun `an import started from select completes the overview only once it succeeds`() = test { + val contacts = listOf(createProfile(publicKey = "pubkyalice"), createProfile(publicKey = "pubkybob")) + listOf( + ImportEnd("succeeds", succeeds = true, clearsPending = true), + ImportEnd("fails", succeeds = false, clearsPending = false), + ImportEnd("pending cleared without a success", succeeds = false, clearsPending = true), + ).forEachCase({ it.case }) { (case, succeeds, clearsPending) -> + reset(pubkyRepo) + isImportingContacts.value = false + stubPendingImport(profile = createProfile(publicKey = "pubkyself"), contacts = contacts) + val sut = createSut() + advanceUntilIdle() + isImportingContacts.value = true + advanceUntilIdle() + + if (clearsPending) { + pendingImportProfile.value = null + pendingImportContacts.value = emptyList() + } + if (succeeds) contactImportVersion.value++ + isImportingContacts.value = false + advanceUntilIdle() + + assertFalse(sut.uiState.value.isImporting, case) + assertEquals(succeeds, sut.uiState.value.shouldRedirectToPayContacts, case) + sut.viewModelScope.cancel() + } + } + + @Test + fun `back during import all keeps the pending import and never opens pay contacts`() = test { + val contacts = listOf(createProfile(publicKey = "pubkyalice"), createProfile(publicKey = "pubkybob")) + listOf( + "fails" to Result.failure(AppError("Storage unavailable")), + "succeeds" to Result.success(Unit), + ).forEachCase({ it.first }) { (case, result) -> + reset(pubkyRepo) + isImportingContacts.value = false + stubPendingImport(profile = createProfile(publicKey = "pubkyself"), contacts = contacts) + whenever(pubkyRepo.clearPendingImport()).thenAnswer { + pendingImportProfile.value = null + pendingImportContacts.value = emptyList() + } + val save = CompletableDeferred>() + whenever(pubkyRepo.importContacts(contacts)).doSuspendableAnswer { + isImportingContacts.value = true + save.await().also { + if (it.isSuccess) contactImportVersion.value++ + isImportingContacts.value = false + } + } + val sut = createSut() + val effects = mutableListOf() + val effectsJob = launch { sut.effects.collect { effects.add(it) } } + advanceUntilIdle() + + sut.importAll() + advanceUntilIdle() + sut.onBackClick() + advanceUntilIdle() + assertEquals(listOf(ContactImportOverviewEffect.NavigateBack), effects, case) + verify(pubkyRepo).discardPendingImport() + verify(pubkyRepo, never()).clearPendingImport() + + save.complete(result) + advanceUntilIdle() + + assertFalse(sut.uiState.value.isImporting, case) + assertFalse(sut.uiState.value.shouldRedirectToPayContacts, case) + effectsJob.cancel() + sut.viewModelScope.cancel() + } + } + @Test + fun `select and import all are ignored while an import runs`() = test { + val contacts = listOf(createProfile(publicKey = "pubkyalice")) + stubPendingImport(profile = createProfile(publicKey = "pubkyself"), contacts = contacts) + isImportingContacts.value = true + val sut = createSut() val effects = mutableListOf() val effectsJob = launch { sut.effects.collect { effects.add(it) } } advanceUntilIdle() + assertTrue(sut.uiState.value.isImporting) + sut.navigateToSelect() sut.importAll() advanceUntilIdle() - verify(pubkyRepo).clearPendingImport() - assertEquals(ContactImportOverviewEffect.ImportComplete, effects.last()) + assertTrue(effects.isEmpty()) + verify(pubkyRepo, never()).importContacts(any()) + + isImportingContacts.value = false + advanceUntilIdle() + assertFalse(sut.uiState.value.isImporting) + sut.navigateToSelect() + advanceUntilIdle() + assertEquals(listOf(ContactImportOverviewEffect.NavigateToSelect), effects) effectsJob.cancel() } @Test - fun `onBackClick clears pending import and navigates back`() = test { + fun `onBackClick discards pending import and navigates back`() = test { stubPendingImport( profile = createProfile(publicKey = "pubkyself"), contacts = listOf(createProfile(publicKey = "pubkyalice")), @@ -88,20 +196,23 @@ class ContactImportOverviewViewModelTest : BaseUnitTest() { sut.onBackClick() advanceUntilIdle() - verify(pubkyRepo).clearPendingImport() + verify(pubkyRepo).discardPendingImport() assertEquals(ContactImportOverviewEffect.NavigateBack, effects.last()) effectsJob.cancel() } private fun createSut() = ContactImportOverviewViewModel( - context = context, pubkyRepo = pubkyRepo, ) private fun stubPendingImport(profile: PubkyProfile?, contacts: List) { - whenever(pubkyRepo.pendingImportProfile).thenReturn(MutableStateFlow(profile)) - whenever(pubkyRepo.pendingImportContacts).thenReturn(MutableStateFlow(contacts)) + pendingImportProfile.value = profile + pendingImportContacts.value = contacts + whenever(pubkyRepo.pendingImportProfile).thenReturn(pendingImportProfile) + whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts) + whenever(pubkyRepo.isImportingContacts).thenReturn(isImportingContacts) + whenever(pubkyRepo.contactImportVersion).thenReturn(contactImportVersion) } private fun createProfile(publicKey: String) = PubkyProfile( @@ -113,4 +224,6 @@ class ContactImportOverviewViewModelTest : BaseUnitTest() { tags = emptyList(), status = null, ) + + private data class ImportEnd(val case: String, val succeeds: Boolean, val clearsPending: Boolean) } diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModelTest.kt index f177b61af2..902de5387f 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactImportSelectViewModelTest.kt @@ -1,12 +1,13 @@ package to.bitkit.ui.screens.contacts -import android.content.Context 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.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile @@ -17,8 +18,8 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ContactImportSelectViewModelTest : BaseUnitTest() { - private val context: Context = mock() private val pubkyRepo: PubkyRepo = mock() + private val isImportingContacts = MutableStateFlow(false) @Test fun `missing pending import redirects to pay contacts`() = test { @@ -53,7 +54,7 @@ class ContactImportSelectViewModelTest : BaseUnitTest() { } @Test - fun `importSelected success clears pending import and completes`() = test { + fun `importSelected completes once the import succeeds`() = test { val contacts = listOf(createProfile(publicKey = "pubkyalice"), createProfile(publicKey = "pubkybob")) stubPendingImport(profile = createProfile(publicKey = "pubkyself"), contacts = contacts) whenever(pubkyRepo.importContacts(contacts)).thenReturn(Result.success(Unit)) @@ -66,20 +67,41 @@ class ContactImportSelectViewModelTest : BaseUnitTest() { sut.importSelected() advanceUntilIdle() - verify(pubkyRepo).clearPendingImport() + verify(pubkyRepo).importContacts(contacts) assertEquals(ContactImportSelectEffect.ImportComplete, effects.last()) effectsJob.cancel() } + @Test + fun `continue is ignored while an import runs`() = test { + val contacts = listOf(createProfile(publicKey = "pubkyalice")) + stubPendingImport(profile = createProfile(publicKey = "pubkyself"), contacts = contacts) + isImportingContacts.value = true + val sut = createSut() + val effects = mutableListOf() + val effectsJob = launch { sut.effects.collect { effects.add(it) } } + advanceUntilIdle() + assertTrue(sut.uiState.value.isImporting) + + sut.importSelected() + advanceUntilIdle() + + assertTrue(effects.isEmpty()) + verify(pubkyRepo, never()).importContacts(any()) + verify(pubkyRepo, never()).clearPendingImport() + + effectsJob.cancel() + } + private fun createSut() = ContactImportSelectViewModel( - context = context, pubkyRepo = pubkyRepo, ) private fun stubPendingImport(profile: PubkyProfile?, contacts: List) { whenever(pubkyRepo.pendingImportProfile).thenReturn(MutableStateFlow(profile)) whenever(pubkyRepo.pendingImportContacts).thenReturn(MutableStateFlow(contacts)) + whenever(pubkyRepo.isImportingContacts).thenReturn(isImportingContacts) } private fun createProfile(publicKey: String) = PubkyProfile( diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactsViewModelTest.kt new file mode 100644 index 0000000000..bcc767c8dc --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactsViewModelTest.kt @@ -0,0 +1,60 @@ +package to.bitkit.ui.screens.contacts + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PubkyRepo +import to.bitkit.test.BaseUnitTest +import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ContactsViewModelTest : BaseUnitTest() { + private val pubkyRepo: PubkyRepo = mock() + private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase = mock() + private val contacts = MutableStateFlow>(emptyList()) + private val isLoadingContacts = MutableStateFlow(false) + private val contactsLoadVersion = MutableStateFlow(0L) + + @Before + fun setUp() { + whenever(pubkyRepo.contacts).thenReturn(contacts) + whenever(pubkyRepo.isLoadingContacts).thenReturn(isLoadingContacts) + whenever(pubkyRepo.contactsLoadVersion).thenReturn(contactsLoadVersion) + whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(null)) + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) + whenever(pubkyRepo.displayName).thenReturn(MutableStateFlow(null)) + whenever(pubkyRepo.displayImageUri).thenReturn(MutableStateFlow(null)) + } + + @Test + fun `full screen loading shows only until the saved records first load`() = test { + val sut = ContactsViewModel(pubkyRepo, refreshContactPaykitReceivers) + backgroundScope.launch { sut.uiState.collect {} } + isLoadingContacts.value = true + advanceUntilIdle() + assertTrue(sut.uiState.value.isLoading) + assertFalse(sut.uiState.value.isEmpty) + + contactsLoadVersion.value = 1L + isLoadingContacts.value = false + advanceUntilIdle() + isLoadingContacts.value = true + advanceUntilIdle() + + assertFalse(sut.uiState.value.isLoading) + assertTrue(sut.uiState.value.isEmpty) + + contacts.value = listOf(PubkyProfile.placeholder("pubkyalice")) + advanceUntilIdle() + assertEquals(listOf("pubkyalice"), sut.uiState.value.contacts.map { it.publicKey }) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/EditContactViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/EditContactViewModelTest.kt index 1952c99fe8..9fce2d751a 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/EditContactViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/EditContactViewModelTest.kt @@ -2,17 +2,23 @@ package to.bitkit.ui.screens.contacts import android.content.Context import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.reset import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.test.forEachCase import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -68,6 +74,72 @@ class EditContactViewModelTest : BaseUnitTest() { assertEquals("https://updated.example.com", sut.uiState.value.links.first().url) } + @Test + fun `a profile update of this or another contact keeps unsaved edits`() = test { + val alice = createContact() + val bob = createContact().copy(publicKey = "pubkyother", name = "Bob") + listOf( + Triple("same contact", listOf(alice), listOf(alice.copy(name = "Alice Resolved", bio = "Updated"))), + Triple("another contact", listOf(alice, bob), listOf(alice, bob.copy(name = "Bob Resolved"))), + ).forEachCase({ it.first }) { (case, initial, updated) -> + reset(pubkyRepo) + val contacts = MutableStateFlow(initial) + whenever(pubkyRepo.contacts).thenReturn(contacts) + val sut = createSut() + advanceUntilIdle() + + sut.onNameChange("Alice Edited") + contacts.value = updated + advanceUntilIdle() + + assertEquals("Alice Edited", sut.uiState.value.name, case) + assertEquals("Hello", sut.uiState.value.bio, case) + } + } + + @Test + fun `form waits for the profile of a contact still showing its label`() = test { + val labelOnly = PubkyProfile.forDisplay(TEST_PUBLIC_KEY, "Alice", imageUrl = null) + val contacts = MutableStateFlow(listOf(labelOnly)) + whenever(pubkyRepo.contacts).thenReturn(contacts) + val lookup = CompletableDeferred() + whenever(pubkyRepo.resolvePendingContactProfile(TEST_PUBLIC_KEY)).doSuspendableAnswer { + lookup.await() + contacts.value = listOf(createContact()) + } + val sut = createSut() + advanceUntilIdle() + + assertTrue(sut.uiState.value.isLoading) + lookup.complete(Unit) + advanceUntilIdle() + + val state = sut.uiState.value + assertFalse(state.isLoading) + assertEquals("Hello", state.bio) + assertEquals("https://example.com/avatar.jpg", state.imageUrl) + assertEquals(listOf("Website"), state.links.map { it.label }) + } + + @Test + fun `a contact whose profile lookup failed shows its label in the form and saves`() = test { + whenever(context.getString(any())).thenReturn("") + val labelOnly = PubkyProfile.forDisplay(TEST_PUBLIC_KEY, "Alice", imageUrl = null) + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(labelOnly))) + whenever(pubkyRepo.updateContact(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isMissing) + assertEquals("Alice", sut.uiState.value.name) + sut.onBioChange("Met at a meetup") + sut.save() + advanceUntilIdle() + + verify(pubkyRepo).updateContact(TEST_PUBLIC_KEY, "Alice", "Met at a meetup", null, emptyList(), emptyList()) + } + @Test fun `contact still missing after refresh produces missing state`() = test { val contacts = MutableStateFlow>(emptyList()) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index 1d73baf669..d6b042b652 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -9,8 +9,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test -import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder @@ -19,14 +19,18 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.PubkyCachedProfile import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.test.forEachCase import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -35,6 +39,151 @@ class ProfileViewModelTest : BaseUnitTest() { private val pubkyRepo: PubkyRepo = mock() private val privatePaykitRepo: PrivatePaykitRepo = mock() + @Test + fun `init loads the profile unless a profile load is in flight`() = test { + val loaded = createProfile() + listOf( + InitLoadCase("none loaded", profile = null, isLoading = false, loads = 1), + InitLoadCase("loaded for the current key", profile = loaded, isLoading = false, loads = 1), + InitLoadCase("loaded for another key", loaded.copy(publicKey = "pubkybob"), isLoading = false, loads = 1), + InitLoadCase("load in flight", profile = null, isLoading = true, loads = 0), + ).forEachCase({ it.name }) { case -> + clearInvocations(pubkyRepo) + val sut = createSut(case.profile, isLoading = case.isLoading) + advanceUntilIdle() + + verify(pubkyRepo, times(case.loads).description(case.name)).loadProfile() + assertEquals(case.profile, sut.uiState.value.profile, case.name) + } + } + + @Test + fun `init loads once more only when the in-flight load fails`() = test { + listOf( + Triple("succeeds", createProfile(), 0), + Triple("fails", null, 1), + ).forEachCase({ it.first }) { (case, loaded, loads) -> + clearInvocations(pubkyRepo) + val profileFlow = MutableStateFlow(null) + val isLoadingFlow = MutableStateFlow(true) + createSut( + profileFlow = profileFlow, + isLoadingFlow = isLoadingFlow, + onLoadProfile = { + isLoadingFlow.value = true + isLoadingFlow.value = false + }, + ) + advanceUntilIdle() + + profileFlow.value = loaded + isLoadingFlow.value = false + advanceUntilIdle() + + verify(pubkyRepo, times(loads).description(case)).loadProfile() + } + } + + @Test + fun `retry loads the profile even when it is already loaded`() = test { + val sut = createSut(createProfile()) + advanceUntilIdle() + + sut.loadProfile() + advanceUntilIdle() + + verify(pubkyRepo, times(2)).loadProfile() + } + + @Test + fun `initial state is seeded from the repository`() = test { + val profile = createProfile() + val sut = createSut(profile = profile, isLoading = true) + + assertEquals(profile, sut.uiState.value.profile) + assertEquals("pubkyalice", sut.uiState.value.publicKey) + assertTrue(sut.uiState.value.isLoading) + } + + @Test + fun `cached profile shows while loading only when its owner is the public key`() = test { + listOf( + Triple("same owner", "pubkyalice" to "pubkyalice", true), + Triple("other owner", "pubkyalice" to "pubkybob", false), + Triple("no public key", null to "pubkyalice", false), + ).forEachCase({ it.first }) { (case, keys, shown) -> + clearInvocations(pubkyRepo) + val (publicKey, owner) = keys + val cachedProfile = createCachedProfile(publicKey = owner) + val sut = createSut(publicKey = publicKey, isLoading = true, cachedProfile = cachedProfile) + + assertEquals(cachedProfile.takeIf { shown }, sut.uiState.value.cachedProfile, case) + } + } + + @Test + fun `first state shows the cached profile for the load started in init`() = test { + val isLoadingFlow = MutableStateFlow(false) + val cachedProfile = createCachedProfile(publicKey = "pubkyalice") + val sut = createSut( + isLoadingFlow = isLoadingFlow, + cachedProfile = cachedProfile, + onLoadProfile = { isLoadingFlow.value = true }, + ) + + assertTrue(sut.uiState.value.isLoading) + assertEquals(cachedProfile, sut.uiState.value.cachedProfile) + } + + @Test + fun `failed load hides the cached profile so the retry state shows`() = test { + val isLoadingFlow = MutableStateFlow(true) + val sut = createSut( + isLoadingFlow = isLoadingFlow, + cachedProfile = createCachedProfile(publicKey = "pubkyalice"), + ) + + sut.uiState.test { + assertNotNull(awaitItem().cachedProfile) + + isLoadingFlow.value = false + + val state = awaitItem() + assertNull(state.profile) + assertFalse(state.isLoading) + assertNull(state.cachedProfile) + } + } + + @Test + fun `session restore keeps loading and shows the cached profile only once its key is known`() = test { + val publicKeyFlow = MutableStateFlow(null) + val isRestoringFlow = MutableStateFlow(true) + val cachedProfile = createCachedProfile(publicKey = "pubkyalice") + val sut = createSut( + publicKeyFlow = publicKeyFlow, + isRestoringFlow = isRestoringFlow, + cachedProfile = cachedProfile, + ) + advanceUntilIdle() + + sut.uiState.test { + var state = awaitItem() + assertTrue(state.isLoading) + assertNull(state.cachedProfile) + + publicKeyFlow.value = "pubkyalice" + state = awaitItem() + assertTrue(state.isLoading) + assertEquals(cachedProfile, state.cachedProfile) + + isRestoringFlow.value = false + state = awaitItem() + assertFalse(state.isLoading) + assertNull(state.cachedProfile) + } + } + @Test fun `disconnect finishes private cleanup after the screen is closed`() = test { val sut = createSut() @@ -124,6 +273,19 @@ class ProfileViewModelTest : BaseUnitTest() { } } + @Test + fun `cached profile keeps profile edits disabled until the profile loads`() = test { + val sut = createSut(isLoading = true, cachedProfile = createCachedProfile(publicKey = "pubkyalice")) + advanceUntilIdle() + + sut.addTag("Bitcoin") + sut.removeTag("Founder") + advanceUntilIdle() + + assertNull(sut.uiState.value.profile) + verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) + } + @Test fun `signOut marks profile recovery before signing out`() = test { whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit)) @@ -293,18 +455,27 @@ class ProfileViewModelTest : BaseUnitTest() { } } + @Suppress("LongParameterList") private fun createSut( profile: PubkyProfile? = null, profileFlow: MutableStateFlow = MutableStateFlow(profile), + publicKey: String? = "pubkyalice", + publicKeyFlow: MutableStateFlow = MutableStateFlow(publicKey), + isLoading: Boolean = false, + isLoadingFlow: MutableStateFlow = MutableStateFlow(isLoading), + isRestoringFlow: MutableStateFlow = MutableStateFlow(false), + cachedProfile: PubkyCachedProfile? = null, + onLoadProfile: () -> Unit = {}, ): ProfileViewModel { whenever(context.getString(any())).thenReturn("") whenever { pubkyRepo.forgetUnrestoredIdentity() }.thenReturn(Result.success(false)) whenever(pubkyRepo.profile).thenReturn(profileFlow) - whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow("pubkyalice")) - whenever(pubkyRepo.isLoadingProfile).thenReturn(MutableStateFlow(false)) - whenever(pubkyRepo.isRestoringSession).thenReturn(MutableStateFlow(false)) + whenever(pubkyRepo.publicKey).thenReturn(publicKeyFlow) + whenever(pubkyRepo.isLoadingProfile).thenReturn(isLoadingFlow) + whenever(pubkyRepo.isRestoringSession).thenReturn(isRestoringFlow) + whenever(pubkyRepo.cachedProfile).thenReturn(MutableStateFlow(cachedProfile)) whenever { pubkyRepo.restoreSessionIfNeeded() }.thenReturn(false) - whenever { pubkyRepo.loadProfile() }.thenReturn(Unit) + whenever { pubkyRepo.loadProfile() }.thenAnswer { onLoadProfile() } whenever { pubkyRepo.signOut() }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.saveProfile(any(), any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } @@ -318,6 +489,12 @@ class ProfileViewModelTest : BaseUnitTest() { ) } + private fun createCachedProfile(publicKey: String) = PubkyCachedProfile( + publicKey = publicKey, + name = "Alice", + imageUri = "pubky://avatar", + ) + private fun createProfile(tags: List = listOf("Founder")) = PubkyProfile( publicKey = "pubkyalice", name = "Alice", @@ -329,4 +506,6 @@ class ProfileViewModelTest : BaseUnitTest() { ) } +private class InitLoadCase(val name: String, val profile: PubkyProfile?, val isLoading: Boolean, val loads: Int) + private class ProfileTestAppError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt index fece646fec..41334a4186 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt @@ -2,19 +2,28 @@ package to.bitkit.ui.screens.profile import android.content.Context import kotlinx.collections.immutable.persistentListOf +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.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.description +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.test.forEachCase import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import kotlin.test.assertEquals @@ -26,6 +35,8 @@ import kotlin.test.assertTrue class PubkyChoiceViewModelTest : BaseUnitTest() { companion object { private const val RING_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val OTHER_RING_PUBKY = "1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + private const val THIRD_RING_PUBKY = "5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" } private val context: Context = mock() @@ -43,6 +54,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts) whenever { pubkyRepo.ringIdentities() }.thenReturn(Result.success(persistentListOf())) whenever { pubkyRepo.prepareImport() }.thenReturn(Result.success(Unit)) + whenever { pubkyRepo.fetchDisplayProfile(any()) }.thenReturn(Result.success(null)) } private fun createSut() { @@ -50,29 +62,185 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { } @Test - fun `ring identities are listed with their remote profile`() = test { - whenever(pubkyRepo.ringIdentities()).thenReturn(Result.success(persistentListOf(RING_PUBKY))) - whenever(pubkyRepo.fetchRemoteProfile(RING_PUBKY)).thenReturn( - Result.success(PubkyProfile.forDisplay(RING_PUBKY, name = "Satoshi", imageUrl = "https://image")) + fun `ring identities are listed before their profiles resolve and each row looks up until it finds one`() = test { + val ringLookup = CompletableDeferred>() + val otherLookup = CompletableDeferred>() + whenever(pubkyRepo.ringIdentities()) + .thenReturn(Result.success(persistentListOf(RING_PUBKY, OTHER_RING_PUBKY))) + whenever(pubkyRepo.fetchDisplayProfile(RING_PUBKY)).doSuspendableAnswer { ringLookup.await() } + whenever(pubkyRepo.fetchDisplayProfile(OTHER_RING_PUBKY)).doSuspendableAnswer { otherLookup.await() } + createSut() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isLoading) + assertEquals(listOf("3rsd...w5xg", "1rsd...w5xy"), sut.uiState.value.identities.map { it.name }) + assertEquals(listOf("3RSD...W5XG", "1RSD...W5XY"), sut.uiState.value.identities.map { it.caption }) + assertEquals(listOf(true, true), sut.uiState.value.identities.map { it.isLookingUp }) + + val otherProfile = PubkyProfile.forDisplay(OTHER_RING_PUBKY, name = "Hal", imageUrl = null) + otherLookup.complete(Result.success(otherProfile)) + advanceUntilIdle() + assertEquals(listOf("3rsd...w5xg", "Hal"), sut.uiState.value.identities.map { it.name }) + assertEquals(listOf(true, false), sut.uiState.value.identities.map { it.isLookingUp }) + assertEquals(otherProfile, sut.uiState.value.identities.last().profile) + + val ringProfile = PubkyProfile.forDisplay(RING_PUBKY, name = "Satoshi", imageUrl = "https://image") + ringLookup.complete(Result.success(ringProfile)) + advanceUntilIdle() + assertEquals( + persistentListOf( + RingIdentity(pubky = RING_PUBKY, profile = ringProfile), + RingIdentity(pubky = OTHER_RING_PUBKY, profile = otherProfile), + ), + sut.uiState.value.identities, ) + assertEquals(listOf(false, false), sut.uiState.value.identities.map { it.isLookingUp }) + assertEquals("https://image", sut.uiState.value.identities.first().imageUrl) + } + + @Test + fun `rows stop showing their lookup when it finds nothing, fails or returns another key`() = test { + val notFoundLookup = CompletableDeferred>() + val failedLookup = CompletableDeferred>() + val mismatchedLookup = CompletableDeferred>() + whenever(pubkyRepo.ringIdentities()) + .thenReturn(Result.success(persistentListOf(RING_PUBKY, OTHER_RING_PUBKY, THIRD_RING_PUBKY))) + whenever(pubkyRepo.fetchDisplayProfile(RING_PUBKY)).doSuspendableAnswer { notFoundLookup.await() } + whenever(pubkyRepo.fetchDisplayProfile(OTHER_RING_PUBKY)).doSuspendableAnswer { failedLookup.await() } + whenever(pubkyRepo.fetchDisplayProfile(THIRD_RING_PUBKY)).doSuspendableAnswer { mismatchedLookup.await() } createSut() + advanceUntilIdle() + assertEquals(listOf(true, true, true), sut.uiState.value.identities.map { it.isLookingUp }) + + notFoundLookup.complete(Result.success(null)) advanceUntilIdle() + assertEquals(listOf(false, true, true), sut.uiState.value.identities.map { it.isLookingUp }) - assertFalse(sut.uiState.value.isLoading) + failedLookup.complete(Result.failure(PubkyChoiceTestAppError("lookup failed"))) + advanceUntilIdle() + assertEquals(listOf(false, false, true), sut.uiState.value.identities.map { it.isLookingUp }) + + val ringProfile = PubkyProfile.forDisplay(RING_PUBKY, name = "Satoshi", imageUrl = null) + mismatchedLookup.complete(Result.success(ringProfile)) + advanceUntilIdle() assertEquals( persistentListOf( - RingIdentity( - pubky = RING_PUBKY, - caption = "3RSD...W5XG", - name = "Satoshi", - imageUrl = "https://image", - ) + RingIdentity(pubky = RING_PUBKY), + RingIdentity(pubky = OTHER_RING_PUBKY), + RingIdentity(pubky = THIRD_RING_PUBKY), ), sut.uiState.value.identities, ) } + @Test + fun `onIdentityClick hands only a profile found for the row's own key to adoption`() = test { + val satoshi = PubkyProfile.forDisplay(RING_PUBKY, name = "Satoshi", imageUrl = null) + val failure = Result.failure(PubkyChoiceTestAppError("lookup failed")) + listOf( + RingRow("found", RING_PUBKY, Result.success(satoshi), rowName = "Satoshi", handoff = satoshi), + RingRow("failed", RING_PUBKY, failure, rowName = "3rsd...w5xg", handoff = null), + RingRow("another key", OTHER_RING_PUBKY, Result.success(satoshi), rowName = "1rsd...w5xy", handoff = null), + ).forEachCase({ it.name }) { case -> + reset(pubkyRepo) + setUp() + whenever(pubkyRepo.ringIdentities()).thenReturn(Result.success(persistentListOf(case.pubky))) + whenever(pubkyRepo.fetchDisplayProfile(case.pubky)).thenReturn(case.lookup) + val handoffs = mutableListOf() + whenever(pubkyRepo.adoptRingIdentity(any(), any())).doSuspendableAnswer { + handoffs += it.getArgument<() -> PubkyProfile?>(1)() + Result.success(case.handoff != null) + } + createSut() + advanceUntilIdle() + + assertEquals(listOf(case.rowName), sut.uiState.value.identities.map { it.name }, case.name) + assertEquals(case.handoff, sut.uiState.value.identities.single().profile, case.name) + sut.onIdentityClick(case.pubky) + advanceUntilIdle() + + verify(pubkyRepo, description(case.name)).adoptRingIdentity(eq(case.pubky), any()) + assertEquals(listOf(case.handoff), handoffs, case.name) + } + } + + @Test + fun `onIdentityClick hands over a row profile that resolves during sign-in`() = test { + val lookup = CompletableDeferred>() + val signIn = CompletableDeferred() + val handoffs = mutableListOf() + whenever(pubkyRepo.ringIdentities()).thenReturn(Result.success(persistentListOf(RING_PUBKY))) + whenever(pubkyRepo.fetchDisplayProfile(RING_PUBKY)).doSuspendableAnswer { lookup.await() } + whenever(pubkyRepo.adoptRingIdentity(eq(RING_PUBKY), any())).doSuspendableAnswer { + signIn.await() + handoffs += it.getArgument<() -> PubkyProfile?>(1)() + Result.success(true) + } + createSut() + advanceUntilIdle() + + sut.onIdentityClick(RING_PUBKY) + advanceUntilIdle() + val profile = PubkyProfile.forDisplay(RING_PUBKY, name = "Satoshi", imageUrl = null) + lookup.complete(Result.success(profile)) + advanceUntilIdle() + signIn.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf(profile), handoffs) + } + + @Test + fun `other rows still resolve after a failed adoption`() = test { + val otherLookup = CompletableDeferred>() + whenever(pubkyRepo.ringIdentities()) + .thenReturn(Result.success(persistentListOf(RING_PUBKY, OTHER_RING_PUBKY))) + whenever(pubkyRepo.fetchDisplayProfile(OTHER_RING_PUBKY)).doSuspendableAnswer { otherLookup.await() } + whenever(pubkyRepo.adoptRingIdentity(eq(RING_PUBKY), any())) + .thenReturn(Result.failure(PubkyChoiceTestAppError("adopt failed"))) + createSut() + advanceUntilIdle() + + sut.onIdentityClick(RING_PUBKY) + advanceUntilIdle() + assertNull(sut.uiState.value.adoptingPubky) + + otherLookup.complete(Result.success(PubkyProfile.forDisplay(OTHER_RING_PUBKY, name = "Hal", imageUrl = null))) + advanceUntilIdle() + + assertEquals(listOf("3rsd...w5xg", "Hal"), sut.uiState.value.identities.map { it.name }) + } + + @Test + fun `onIdentityClick ignores further taps while an adoption is running`() = test { + val finishAdoption = CompletableDeferred>() + val otherLookup = CompletableDeferred>() + whenever(pubkyRepo.ringIdentities()) + .thenReturn(Result.success(persistentListOf(RING_PUBKY, OTHER_RING_PUBKY))) + whenever(pubkyRepo.fetchDisplayProfile(OTHER_RING_PUBKY)).doSuspendableAnswer { otherLookup.await() } + whenever(pubkyRepo.adoptRingIdentity(eq(RING_PUBKY), any())).doSuspendableAnswer { finishAdoption.await() } + createSut() + advanceUntilIdle() + + sut.onIdentityClick(RING_PUBKY) + sut.onIdentityClick(OTHER_RING_PUBKY) + advanceUntilIdle() + otherLookup.complete(Result.success(PubkyProfile.forDisplay(OTHER_RING_PUBKY, name = "Hal", imageUrl = null))) + advanceUntilIdle() + sut.onIdentityClick(OTHER_RING_PUBKY) + sut.onIdentityClick(RING_PUBKY) + advanceUntilIdle() + + assertEquals(RING_PUBKY, sut.uiState.value.adoptingPubky) + assertEquals(listOf("3rsd...w5xg", "Hal"), sut.uiState.value.identities.map { it.name }) + finishAdoption.complete(Result.success(false)) + advanceUntilIdle() + + verify(pubkyRepo, times(1)).adoptRingIdentity(any(), any()) + assertNull(sut.uiState.value.adoptingPubky) + } + @Test fun `listing failure shows no identities`() = test { whenever(pubkyRepo.ringIdentities()).thenReturn(Result.failure(PubkyChoiceTestAppError("query failed"))) @@ -86,7 +254,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { @Test fun `onIdentityClick continues to contact import when the adopted identity has follows`() = test { - whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(true)) + whenever(pubkyRepo.adoptRingIdentity(eq(RING_PUBKY), any())).thenReturn(Result.success(true)) pendingImportContacts.value = listOf(PubkyProfile.placeholder("pubky$RING_PUBKY")) createSut() val effects = mutableListOf() @@ -103,7 +271,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { @Test fun `onIdentityClick continues to pay contacts when the adopted identity has no follows`() = test { - whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(true)) + whenever(pubkyRepo.adoptRingIdentity(eq(RING_PUBKY), any())).thenReturn(Result.success(true)) createSut() val effects = mutableListOf() val toasts = mutableListOf() @@ -123,7 +291,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { @Test fun `onIdentityClick toasts and continues to pay contacts when the follows lookup fails`() = test { - whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(true)) + whenever(pubkyRepo.adoptRingIdentity(eq(RING_PUBKY), any())).thenReturn(Result.success(true)) whenever(pubkyRepo.prepareImport()).thenReturn(Result.failure(PubkyChoiceTestAppError("follows failed"))) createSut() val effects = mutableListOf() @@ -146,7 +314,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { @Test fun `onIdentityClick continues to profile creation when the adopted identity has no profile`() = test { - whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)).thenReturn(Result.success(false)) + whenever(pubkyRepo.adoptRingIdentity(eq(RING_PUBKY), any())).thenReturn(Result.success(false)) createSut() val effects = mutableListOf() val effectsJob = launch { sut.effects.collect { effects.add(it) } } @@ -162,7 +330,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { @Test fun `onIdentityClick clears the adopting identity and toasts when adoption fails`() = test { - whenever(pubkyRepo.adoptRingIdentity(RING_PUBKY)) + whenever(pubkyRepo.adoptRingIdentity(eq(RING_PUBKY), any())) .thenReturn(Result.failure(PubkyChoiceTestAppError("adopt failed"))) createSut() val toasts = mutableListOf() @@ -199,4 +367,12 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { } } +private class RingRow( + val name: String, + val pubky: String, + val lookup: Result, + val rowName: String, + val handoff: PubkyProfile?, +) + private class PubkyChoiceTestAppError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e1971da6d7..408dd78910 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -150,6 +150,7 @@ import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyIdentityReadiness import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo @@ -261,6 +262,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyContacts = MutableStateFlow>(emptyList()) private val pubkyContactsLoadVersion = MutableStateFlow(0L) private val pubkyContactsLoadCompletionVersion = MutableStateFlow(0L) + private val pubkyContactImportFailure = MutableStateFlow(null) private val pendingPaykitPaymentRequests = MutableStateFlow>(emptyList()) private val paykitPaymentRequestHistory = MutableStateFlow>(emptyList()) private val paykitSubscriptions = MutableStateFlow>(emptyList()) @@ -339,6 +341,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `contact import failure shows an error toast after the import screens are gone`() = test { + whenever(context.getString(R.string.common__error)).thenReturn("Error") + whenever(pubkyRepo.clearContactImportFailure()).thenAnswer { pubkyContactImportFailure.value = null } + clearInvocations(toastManager) + + pubkyContactImportFailure.value = AppError("Storage unavailable") + runCurrent() + + verify(toastManager).enqueue( + check { + assertEquals(Toast.ToastType.ERROR, it.type) + assertEquals("Error", it.title) + assertEquals("Storage unavailable", it.description) + } + ) + assertNull(pubkyContactImportFailure.value) + } + @Suppress("LongMethod") private fun stubRepositories() { whenever(context.getString(any())).thenReturn("") @@ -381,9 +402,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit) whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.adoptedSourceLost).thenReturn(MutableStateFlow(false)) + whenever(pubkyRepo.contactImportFailure).thenReturn(pubkyContactImportFailure) whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey) whenever { pubkyRepo.republishIdentityIfNeeded() }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.hasIdentity() }.thenAnswer { pubkyPublicKey.value != null } + whenever { pubkyRepo.awaitIdentityReady() }.thenAnswer { + if (pubkyPublicKey.value != null) PubkyIdentityReadiness.Ready else PubkyIdentityReadiness.Missing + } whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) whenever { refreshContactPaykitReceivers(any()) }.thenReturn(Result.success(Unit)) whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } @@ -3562,6 +3587,101 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `cold pubky auth deeplink waits for a session retry after a failed startup restore`() = test { + enablePaykitUi() + advanceUntilIdle() + val retry = CompletableDeferred() + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + whenever(pubkyRepo.awaitIdentityReady()).doSuspendableAnswer { + retry.await() + pubkyPublicKey.value = testPublicKey + PubkyIdentityReadiness.Ready + } + val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw" + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + runCurrent() + + assertNull(sut.currentSheet.value) + verify(toastManager, never()).enqueue(any()) + retry.complete(Unit) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `pubky auth deeplink shows a retryable error for a saved identity it cannot restore`() = test { + enablePaykitUi() + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + whenever(pubkyRepo.awaitIdentityReady()).thenReturn(PubkyIdentityReadiness.Unavailable) + whenever(context.getString(R.string.pubky_auth__identity_unavailable)) + .thenReturn("Couldn't Load Your Pubky Profile") + whenever(context.getString(R.string.pubky_auth__identity_unavailable_desc)) + .thenReturn("Check your connection and try again.") + advanceUntilIdle() + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri())) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(pubkyRepo).awaitIdentityReady() + verify(context, never()).getString(R.string.pubky_auth__no_identity) + verify(toastManager).enqueue( + check { + assertEquals(Toast.ToastType.ERROR, it.type) + assertEquals("Couldn't Load Your Pubky Profile", it.title) + assertEquals("Check your connection and try again.", it.description) + } + ) + } + + @Test + fun `global scanner shows a retryable error for a saved identity without a session`() = test { + enablePaykitUi() + whenever(pubkyRepo.hasIdentity()).thenReturn(true) + whenever(context.getString(R.string.pubky_auth__identity_unavailable)) + .thenReturn("Couldn't Load Your Pubky Profile") + advanceUntilIdle() + + sut.showScannerSheet() + advanceUntilIdle() + sut.onScannerSheetResult("pubkyauth://auth?caps=/pub/paykit/v0/:rw") + advanceUntilIdle() + + verify(context, never()).getString(R.string.pubky_auth__no_identity) + verify(toastManager).enqueue(check { assertEquals("Couldn't Load Your Pubky Profile", it.title) }) + } + + @Test + fun `pubky auth deeplink session retry timeout releases scan lock`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.setIsAuthenticated(true) + whenever(pubkyRepo.awaitIdentityReady()).doSuspendableAnswer { awaitCancellation() } + whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization failed") + whenever(context.getString(R.string.profile__auth_error_timeout)).thenReturn("Authorization timed out") + val bolt11 = "lnbcrt1postretrytimeoutscan" + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri())) + advanceTimeBy(PubkyService.AUTHORIZATION_TIMEOUT.inWholeMilliseconds) + runCurrent() + + assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + verify(toastManager).enqueue(check { assertEquals("Authorization timed out", it.description) }) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `pubky auth deeplink shows Pubky Ring toast without a usable secret key`() = test { enablePaykitUi() @@ -8309,6 +8429,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).pruneUnsavedContactState(any>()) } + @Test + fun `private Paykit sync ignores contact row updates that keep the same keys`() = test { + enablePaykitUi() + advanceUntilIdle() + val contact = PubkyProfile.placeholder("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + pubkyPublicKey.value = testPublicKey + pubkyContacts.value = listOf(contact) + pubkyContactsLoadVersion.value = 1L + advanceUntilIdle() + verify(privatePaykitRepo).prepareSavedContacts(setOf(contact.publicKey), false) + clearInvocations(privatePaykitRepo) + + pubkyContacts.value = listOf(contact.copy(name = "Bob", imageUrl = "pubky://avatar")) + advanceUntilIdle() + + verify(privatePaykitRepo, never()).prepareSavedContacts(any>(), any()) + verify(privatePaykitRepo, never()).startInitialLinkBurst(any(), any()) + } + @Test fun `private Paykit removes stale contact without duplicate load version cleanup`() = test { enablePaykitUi() diff --git a/changelog.d/next/1399.fixed.md b/changelog.d/next/1399.fixed.md new file mode 100644 index 0000000000..b275e4951c --- /dev/null +++ b/changelog.d/next/1399.fixed.md @@ -0,0 +1 @@ +Pubky profiles and contacts load faster: Pubky Ring identities and saved contacts appear at once, avatars are cached between launches, the Profile screen shows your cached name and avatar while it loads, a contact import no longer stops when you leave its screen, and an authorization link that opens Bitkit waits for your Pubky profile to reconnect instead of asking you to create one. diff --git a/docs/pubky.md b/docs/pubky.md index 12b81d2ebe..3e28828c10 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -30,7 +30,9 @@ Delegates Pubky operations to `PaykitSdkService`, which uses: - **bitkit-core** (`com.synonym:bitkit-core-android`) — mnemonic-to-seed conversion for receiver noise-key derivation - `mnemonicToSeed()` -All calls are dispatched on `ServiceQueue.CORE` (single-thread executor) to ensure serial access to the underlying Rust state. +Session, state, key and publishing calls are serialized by `PaykitSdkService`'s operation lock. The public reads — `fetchFile()` (`fetchPubkyFileBounded()`), `fetchPubkyProfile()`, `fetchPubkyFollows()`, `resolveContactProfile()`, and the receiver reads `discoverRelevantReceiverPaths()`, `privateReceiverPathSelection()` and `paymentRequestReceiverPaths()` (`paykitReceiverPaths()` and `paykitReceiverMarker()`) — run outside that lock, at most 6 at once, and are cancelled with their caller. Only unauthenticated public reads may use that path. Reading or saving a contact record stays under the lock, so a caller that discovers receiver paths and then saves them takes the lock only for the save. A wallet wipe still applies to the public reads: one that needs an SDK instance builds it under the lock, one that starts during the wipe fails the way a locked call does, and one that the wipe overtakes fails instead of returning its result, while the wipe's own cleanup can still read. + +Each public read names a lane. An interactive read, for something the user is looking at or waiting on — the user's own profile, avatars and other files, Pubky Ring choice rows, the follow lookups of `prepareImport()` that an adopted Ring row waits on, a single contact opened from Add Contact or the contact screen, and the receiver reads for one contact the user pays or sends a payment request to — takes one of the 6 read slots. A bulk read — the contacts list's background profile refresh, private sync's receiver discovery and marker reads, and the payment request target refresh over all saved contacts — first takes one of 4 bulk slots and then a read slot, so bulk work never holds more than 4 read slots and at least 2 stay free for interactive reads. A freed read slot goes to the oldest waiting interactive read before any waiting bulk read, also one already queued for a read slot, so a lookup the user waits on never waits behind bulk reads that asked for a slot first. Within each lane reads are first come, first served, and a cancelled read gives back the slots it holds. A caller can give `resolveContactProfile()` a timeout, which counts only while the read holds its read slot; a read that runs out is cancelled, gives back its slots and fails with `PaykitReadTimeoutError`, an ordinary error rather than a cancellation. ## Repository Layer (`PubkyRepo`) @@ -43,6 +45,8 @@ Manages session lifecycle, identity adoption, and profile data. Singleton scoped - `initialize()` attempts to restore any saved session via `importSession()` - If restoration fails, saved credentials and cached profile data remain available for retry - Recovery retries on connectivity restoration and app resume, and an adopted Ring credential can re-sign in the same identity +- A `pubkyauth://` authorization deep link waits for `awaitIdentityReady()` as well as the first restore, so a link that cold-starts Bitkit after the first restore failed waits for the retry instead of reporting a missing identity. When no session is active, `awaitIdentityReady()` retries the restore once on the same lock as the resume retry, adoption, identity creation and backup restore, so it waits for one already running and never restores twice. It returns `Ready`, `Missing` (no saved identity, decided without a network call) or `Unavailable` (a saved identity whose session could not be restored yet; its credentials, cached profile and the session-expired flag are left alone). The retry runs in the repository scope, so a newer scan that supersedes the link cannot interrupt a half-done `importSession()`, and the profile and contacts it loads are not awaited +- An authorization request that finds no active session shows "Pubky Identity Required" only when no identity is saved. With a saved identity that could not be restored, the deep link and the scanner both show a retryable "Couldn't Load Your Pubky Profile" error instead ### Profile Loading @@ -50,6 +54,9 @@ Manages session lifecycle, identity adoption, and profile data. Singleton scoped - Uses a `Mutex` to serialize loads, then re-checks the captured identity before fetching - Re-checks `_publicKey` after the network call to guard against a concurrent `signOut()` - Profile name and image URI are cached in `PubkyStore` (DataStore) for instant display on launch before the full profile loads +- The cache also records the public key it was taken from (`cachedProfileOwner`). Only the profile cache writes that field, and it is cleared together with the cached name and image URI +- `ProfileScreen` shows the cached name and avatar with an inline loading indicator while a load is in flight, but only when the cached owner matches the current public key. `ProfileViewModel` makes this decision and exposes the cached profile only under those conditions. While the session is still restoring there is no public key to match, so Profile shows a plain spinner (`ProfileLoading`) until it is back. Editing, tags and the other profile actions wait for the loaded profile, and a failed load still shows the retry state +- Opening `ProfileScreen` always starts a background refresh, so tag and profile edits build on the latest published profile once it completes. An already loaded profile stays on screen while it runs, and a result overtaken by a save, deletion or identity change is dropped. When a load is already in flight, such as the startup load, the screen waits for it instead of queueing a second load, then loads once more only if it ended without a profile for the current public key and the user is still signed in. Retry always reloads ### Exposed State @@ -60,16 +67,27 @@ Manages session lifecycle, identity adoption, and profile data. Singleton scoped | `isAuthenticated` | True while a public key is set | | `displayName` | Profile name with cached fallback | | `displayImageUri` | Profile image URI with cached fallback | +| `cachedProfile` | Cached profile name and image URI with the public key they belong to | | `isLoadingProfile` | Loading indicator | | `contacts` | List of followed `PubkyProfile` contacts | | `isLoadingContacts` | Contacts loading indicator | +| `isImportingContacts` | True while a contact import runs | +| `contactImportVersion` | Bumped each time a contact import succeeds | +| `contactImportFailure` | The error of a failed contact import until `AppViewModel` shows it | ### Contacts -- `loadContacts()` reads saved Paykit contact records, then concurrently resolves any missing profiles via `resolveContactProfile()` +- `loadContacts()` reads the saved Paykit contact records and publishes them at once, without waiting for any profile lookup. Each row uses, in order, the user's profile override, the record's stored Paykit profile, a profile resolved earlier in this session, then the record's label. A record with neither an override nor a stored profile is then refreshed in the background on the bulk read lane via `resolveContactProfile()`, unless its profile was resolved in this session less than 10 minutes ago (`CONTACT_PROFILE_FRESHNESS`): coming back to Contacts from a contact's screen does not look every contact up again. The refresh applies the profiles it finds in batches, at most once every 300 ms (`CONTACT_REFRESH_BATCH_WINDOW`), sorting and publishing the list once per batch, so the rows do not jump once per contact; the last batch is applied as soon as every lookup of the refresh has finished. A batch is dropped when the refresh was stopped or the identity changed before it was applied. A record whose lookup failed or found no profile has no session profile, so every load looks it up again. A row the user edited or removed meanwhile is left alone, and repeated loads share one refresh while it covers the same contacts +- `isLoadingContacts`, `contactsLoadVersion` and `contactsLoadCompletionVersion` describe the saved records only: a load counts as finished once they are published. Background row updates change neither the version counters nor the set of contact keys, so the private Paykit sync observer in `AppViewModel`, which keys on the contact keys and whether contacts have loaded, does not run again for them +- The session profiles are kept in memory per identity, each with the time it was resolved. `prepareImport()`, `importContacts()`, `addContact()` and background refreshes add to them; sign-out, wipe, profile deletion, backup restore and a switch to another identity clear them, so the next load looks every contact up again, and stop a running refresh +- `ContactsScreen` shows its full-screen spinner only until the saved records first load for the current identity - Contact keys from the FFI may lack the `pubky` prefix; `ensurePubkyPrefix()` normalizes them before profile resolution -- `prepareImport()` discovers followed keys via `fetchPubkyFollows()`, then resolves each profile -- If a contact profile fetch fails, a `PubkyProfile.placeholder()` is used to ensure the contact still appears in the list with a truncated public key +- `prepareImport()` discovers followed keys via `fetchPubkyFollows()` and leaves out the identity's own key, also when it is spelled with other final z-base32 padding bits, then resolves each profile once on the interactive read lane, since the Pubky Ring choice row waits on it. Each follow's lookup may run for 10 seconds once it holds a read slot (`IMPORT_FOLLOW_LOOKUP_TIMEOUT`), so one unreachable key cannot hold the preview for minutes, while time spent waiting behind other lookups for a slot does not count: a lookup still running then is cancelled, which frees its read slot, fails with `PaykitReadTimeoutError` and counts as failed. No other caller sets a timeout, and there is no deadline for the preview as a whole. A follow it cannot resolve, such as a key that never published a profile or one whose lookup timed out, stays in the preview as a placeholder under its truncated public key +- `importContacts()` saves the profiles `prepareImport()` resolved one at a time, labelled with the name the preview shows, without a profile lookup or receiver discovery, so a placeholder is saved under its truncated public key. Each record starts with only the wallet receiver path: private sync (`PrivatePaykitRepo`) discovers a saved contact's receiver paths and merges them into the record before it uses them, and nothing else uses them for payments. Removing a contact blocks the record's receiver paths and those of its linked peers, so a contact private sync never reached blocks only the wallet path. A contact already in the contacts list is skipped. When a save fails, the import keeps the contacts already saved, carries on with the rest and then fails, so the import screen stays open and a retry saves only the missing contacts. A failed import sets `contactImportFailure`, and `AppViewModel` shows its error toast, so the failure shows also after the user left the import screens. An import that stops because the identity changed, such as after a sign-out, reports nothing +- The import runs in `PubkyRepo`'s scope, so leaving the import screens does not stop it half way, and it stops saving once the identity changes. Each save checks the identity again under the Paykit SDK lock, so a save queued behind a sign-in to another identity is not written to that identity. `isImportingContacts` is true while an import runs, and the import screens disable Select, Import All and Continue meanwhile, and a second tap on Import All or Continue does not start a second import. A successful import clears the pending import itself and bumps `contactImportVersion`, so the import overview moves on to Pay Contacts when an import started from the selection screen succeeds after the user went back to it. The overview moves on only after a successful import, never because the pending import emptied, and never once the user tapped Back on it +- Leaving the import screens, by Back on the overview or through the drawer, calls `discardPendingImport()`. It drops the pending import only when no import runs, so a running import still clears it once it succeeds, and a failed one leaves the follows it could not save pending +- A contact without a resolved profile still appears in the list under its label, or its truncated public key when it has none +- `resolvePendingContactProfile()` looks up a saved contact on the interactive read lane while its row still shows only its label because the background refresh has not finished looking it up. That lookup takes the contact over from the refresh: the refresh cancels its own lookup of the contact, so one still queued behind the bulk read lane never runs, and never applies a result for it. A second caller waits for the same lookup, which a later refresh replacing the first leaves running; only a sign-out or an identity change stops it. When the lookup fails or finds no profile, the row keeps its label and the call returns at once rather than waiting for the refresh. When the refresh has already found the contact's profile but is holding it for its next batch, that batch is applied at once instead of a new lookup. The contact screen calls it when it opens and a tag change waits for it before saving; Edit Contact keeps its form loading until it returns. An edit therefore keeps the contact's avatar, which the edit form cannot set, and starts from its bio and links whenever the lookup finds them; when the lookup fails, the edit saves the row as it shows. Once the user changes a field, Edit Contact stops applying later updates of the contact to the form - `fetchContactProfile()` fetches a single contact's profile on demand (used by the detail screen) ## Contacts Flow @@ -95,18 +113,37 @@ Composable for loading and displaying images from `pubky://` URIs, backed by Coi ### Caching Strategy (Coil) -Coil manages a two-tier cache automatically: +Avatars use a two-tier cache: + +1. **Memory** — Coil's `MemoryCache` (15% of app memory), managed by Coil +2. **Disk** — Coil's `DiskCache` in `cacheDir/pubky-images/`, read and written by `PubkyImageFetcher`. Coil only fills the disk cache from its own network fetcher, so the Pubky fetcher manages this directory itself + +Disk cache rules: + +- Entries are keyed by the request's disk cache key, or by the original `pubky://` URI when none is set. For a file descriptor this is the descriptor URI, not the blob `src` +- Reads and writes follow the request's disk cache policy +- Only a fully successful fetch is written: a raw image, or a descriptor whose blob was fetched. A descriptor whose blob fetch failed, and JSON without a Pubky `src`, are never written +- A failed write discards the entry, and the fetched image is still displayed +- Sign-out and a switch to another identity clear the whole directory, because only this fetcher uses it. Sign-out also removes `pubky://` entries from the memory cache +- Each clear first advances `PubkyImageCacheEpoch`. The fetcher reads it before going to the network and commits its write only while it is unchanged, so a fetch in flight across a clear does not re-populate the cleared directory +- Avatars are public data, and the directory is app-private + +Failed fetches are remembered by URI in `PubkyImageFailureCache`, owned by the fetcher factory, so an avatar that cannot load is not fetched again each time a row recomposes or reappears: -1. **Memory** — Coil's `MemoryCache` (15% of app memory) -2. **Disk** — Coil's `DiskCache` in `cacheDir/pubky-images/` +- A missing file or blob (`PubkyFileNotFoundError`, `PaykitException.NotFound`) and an invalid response, such as a file over the 1 MiB limit (`PaykitException.Protocol`), are remembered until the image cache is next cleared +- Any other failure, such as a transport error, is remembered for 60 seconds (`PUBKY_IMAGE_TRANSIENT_FAILURE_TTL`). The SDK reports a key it cannot resolve the same way as a network failure, so such a key is retried every minute +- A descriptor whose blob fetch failed counts as a failure of the descriptor URI, with the blob's failure deciding how long it is kept +- While a failure is remembered, the fetcher fails at once with `PubkyImageRecentlyFailedError` and the image shows its error state. A disk cache hit still wins +- Advancing `PubkyImageCacheEpoch`, as sign-out and a switch to another identity do, forgets every failure, and a failure of a fetch that a clear overtook or that was cancelled is not remembered ### Loading Flow 1. Coil checks memory cache → return if hit -2. Coil checks disk cache → return if hit -3. `PubkyImageFetcher.fetch()` limits the successful response body to 1 MiB -4. If the response is a JSON file descriptor with a Pubky `src`, follow the indirection with the same limit -5. Coil decodes and caches the result +2. `PubkyImageFetcher.fetch()` checks the disk cache → return if hit, without waiting for Paykit setup or using the network +3. A URI whose fetch failed recently fails at once, without using the network +4. Otherwise the fetcher limits the successful response body to 1 MiB +5. If the response is a JSON file descriptor with a Pubky `src`, follow the indirection with the same limit +6. The fetcher writes a fully successful result to the disk cache unless the directory was cleared during the fetch, then Coil decodes it and caches it in memory The bound is enforced while successful response bodies are read, before the bytes cross the FFI boundary. HTTP error bodies can still be buffered by the Pubky client before Paykit regains control. @@ -138,7 +175,8 @@ bodies can still be buffered by the Pubky client before Paykit regains control. | `services/PubkyService.kt` | FFI wrapper | | `repositories/PubkyRepo.kt` | Session management and identity adoption | | `data/sharedpubky/SharedPubkyClient.kt` | Reads Pubky Ring's shared pubky provider | -| `data/PubkyImageFetcher.kt` | Coil fetcher for pubky:// URIs | +| `data/PubkyImageFetcher.kt` | Coil fetcher for pubky:// URIs, with its cache of failed fetches | +| `data/PubkyImageCacheEpoch.kt` | Clear counter that keeps in-flight fetches out of a cleared disk cache and resets the failed fetches | | `di/ImageModule.kt` | Hilt module providing ImageLoader | | `data/PubkyStore.kt` | DataStore for cached profile metadata | | `models/PubkyProfile.kt` | Domain model | diff --git a/journeys/README.md b/journeys/README.md index 8c24ba27d6..ca1aaaf5fc 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -161,6 +161,10 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock; and iOS has no foreground-service path, so `confirmed-only-background-notification.xml` has no counterpart | | `backup/show-mnemonic-long-words.xml` | not ported — the long-word fit is an Android-only change (synonymdev/bitkit-android#633); whether iOS wraps long words at larger text sizes is unchecked | | `deeplinks/pubky-contact.xml` | ported — same contact routing and unlock behavior | +| `pubky-profile/cached-profile-header.xml` | same file, journey name and steps — one step differs: Android adds a screenshot check of the cached avatar, which comes from the Pubky image disk cache synonymdev/bitkit-android#1399 adds | +| `pubky-profile/ring-choice-rows.xml` | same file, journey name and steps — Android names the rows and their lookup spinners differently (see [Identifiers](#identifiers)), checks that rows are disabled by tapping another row instead of reading the snapshot's Targets, needs no second tap on the profile button, and names the import overview by its "Import" title | +| `pubky-profile/contact-import-after-leaving.xml` | same file, journey name and steps — Android names the Ring rows differently and has no identifiers on the import overview's profile and friend count, so those steps name the screen text here; see [Identifiers](#identifiers). synonymdev/bitkit-android#1399 adds `ContactImportOverviewImportAll`, the only contact import testTag the journeys use, under its iOS name | +| `pubky-profile/contacts-list-loading.xml` | same file, journey name and steps — only the relaunch commands differ | | `deeplinks/screen-deeplink.xml`, `deeplinks/sheet-deeplink.xml` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | | `shop/gift-card-category-titles.xml` | not ported — iOS still hardcodes the category names, and its route in has no screen deeplink | @@ -211,10 +215,14 @@ and Settings (`Tab-general`, `Tab-security`, `Tab-advanced`, `NavigationBack`, ` | Receive QR copy button | `ReceiveCopyQR` | `ReceiveCopyQR` *(absent from `snapshot-ui` targets; see below)* | | Payment Request row | `PaymentRequestRow-` | `PaymentRequestRow--` *(`-one-time` for a one-off)* | | Payment Request detail screen | `PaymentRequestDetailsScreen` | `PaymentRequestDetailScreen` | - -Two of those are unreconciled rather than intentional: the Send screen emitting both -`AvailableAmount` and `available_balance`, and the background-payments row name. Settling either is a -code change on one side, not a journey change. +| Pubky Ring choice row | `PubkyChoiceIdentity` *(shared by every row; tell rows apart by their key caption)* | `PubkyChoiceRing_` | +| Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` *(shared by every row; tell rows apart by their key caption)* | `PubkyChoiceRingLookup_` | +| Contact import overview profile and friend count | — *(no tag; the "Import" title and the "N friends" text)* | `ContactImportOverviewProfile`, `ContactImportOverviewSummary` | + +Three of those are unreconciled rather than intentional: the Send screen emitting both +`AvailableAmount` and `available_balance`, the background-payments row name, and the Pubky Ring choice +row. Settling any of them is a code change on one side, not a journey change. The contact import +row names Android testTags not added yet; adding them with the iOS names would settle it too. `SubscriptionRow-` matches on both platforms. iOS appends the billing period to `PaymentRequestRow` because every recurring payment of one subscription shares the same id, so a diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md new file mode 100644 index 0000000000..8209b0c7c0 --- /dev/null +++ b/journeys/pubky-profile/README.md @@ -0,0 +1,136 @@ +# Pubky profile journeys + +These journeys cover the states a Pubky profile passes through while it loads: the Profile screen +opened before the profile has loaded after a relaunch, the Pubky Ring choice screen while its +rows look up their profiles and while one of them is adopted, and Contacts opened after a relaunch +while its profiles load. They also cover a contact import that finishes after you leave it. +Importing contacts itself is covered by [`journeys/contacts`](../contacts/README.md). +`bitkit-ios` carries the same suite with the same file names and journey names, and the steps differ +only where the platform forces it; see [Android vs iOS](#android-vs-ios). + +## What the behaviour is + +- **Profile shows a cached header while it loads.** `PubkyStore` keeps the last loaded name, avatar + URI and the public key they belong to. While a load is in flight for that same key, + `ProfileScreen` shows a read-only header with the cached name, avatar and truncated key and an + inline spinner (`ProfileCachedHeader`, `ProfileCachedName`). Edit, Copy, the QR code, Share and + Tags appear only with the loaded profile. A failed load still ends on the retry state + (`ProfileRetry`). The avatar comes from the Pubky image disk cache, so it shows without the + network. +- **Ring rows show at once.** The choice screen lists Ring's identities as soon as the shared pubky + provider answers. Each row is captioned and titled with its truncated key, and a row's title + becomes the profile name when that row's lookup finishes, rather than the screen waiting for all + of them. While a row's lookup runs, a spinner (`PubkyChoiceIdentityLookup`) stands in for its + avatar, so a row still looking up does not look like a row whose identity has no profile. +- **Adoption marks one row.** Tapping a row shows a spinner in place of that row's key icon only, + keeps its avatar, and disables every row until the adoption succeeds or fails. On Android the + other rows' lookups keep running during an adoption, so a name can still fill in on a disabled + row; the journey does not assert on that either way. +- **The import keeps going after you leave it.** Import All saves the follows the overview already + looked up, only to the device, as `journeys/contacts` describes. While the import runs, Import All + shows a spinner and both buttons are disabled. The import belongs to the app rather than the + screen, so leaving the overview does not stop it, and an import that finishes after you left does + not take you to Pay Contacts. `contact-import-after-leaving.xml` checks the leaving; the saving is + covered by `journeys/contacts`. The overview does not wait long on any one follow: on Android a + follow whose profile lookup runs ten seconds without an answer shows under its truncated key. + Waiting behind other lookups does not count towards those ten seconds. +- **Contacts lists saved contacts at once.** Contacts shows every saved contact as soon as the saved + records are read, under its saved name or truncated key, and fills in a name and avatar when that + contact's profile lookup finishes; a lookup that fails leaves the row as it is. On Android the + rows fill in a few at a time, at most every 300 ms, rather than the list re-sorting once per + contact. The screen-wide spinner shows only until the saved records first load. Reopening + Contacts in the same session shows the profiles already found at once; on Android it also does + not look up again a profile found less than ten minutes ago, so only contacts still without a + profile get a new lookup. Opening a contact whose profile has not loaded yet looks it up at once, + and its edit form shows the published bio. + +## Setup + +1. **Slow the network on an emulator.** On a fast network the profile load, the row lookups, the + adoption, the import and the contact lookups can all finish before the screen is inspected. The + journeys keep the iOS steps, which report "already loaded", "already resolved", "already + adopted" or "already imported" instead of failing, so slow the network with + `adb emu network delay gprs` and `adb emu network speed edge` before a journey's first step and + restore it with `adb emu network delay none` and `adb emu network speed full` after its last to + catch the loading states. A contact import saves only to the device, so the throttle does not + slow it; it can finish before you leave it, and the journey then reports "already imported". + `adb emu` works only on an emulator. If a state still passes too quickly, use `adb emu network speed gsm`; if loads start + failing, relax the throttle. +2. **Cached header:** an active Pubky profile with a name and an avatar, PIN off, and Profile + opened once on this install so the cache is filled. The journey does that as its first steps. +3. **Ring rows (not a listed capability):** Pubky Ring (`to.pubky.ring`) installed on the same + device with at least two identities, at least one with a published profile name, and no active + Pubky identity in Bitkit. Bitkit reads Ring's shared pubky provider only when both apps are + signed with the same key, so a dev build needs a Ring build signed with `app/debug.keystore`. + Without that, the choice screen offers only "Create profile with Bitkit" (`PubkyChoiceCreate`). + The journey adopts one identity; sign out of it in Bitkit before running the journey again. +4. **Contact import after leaving (not a listed capability):** the same Ring setup, with an + identity that has a published profile and follows many pubkys on pubky.app (62, as in + `journeys/contacts/import-all-contacts.xml`), so the import takes long enough to leave. The journey saves the follows as contacts; sign out in + Bitkit before running it again. +5. **Contacts list loading:** a Pubky identity with at least five saved contacts, at least one with + a published profile name and bio and one with no published profile. Importing such follows with + `journeys/contacts/import-all-contacts.xml` leaves exactly that. + +## Gotchas + +- **The session can restore after the profile button is tapped.** Profile then shows a spinner with + no name (`ProfileLoading`) until the session is back, and only then the cached header. +- **Every choice row shares one test tag.** Tell the rows apart by their key caption. iOS gives each + row its own identifier; see the identifier table in `journeys/README.md`. +- **The avatar has no text.** Check it from a screenshot rather than `android layout`. +- **Contacts can open the wrong screen right after a relaunch.** While the session is still + restoring, the menu's Contacts item opens the contacts intro or Profile instead. Return to the + home screen and open Contacts again. +- **The edit form calls the notes field "Bio".** `contacts-list-loading.xml` keeps the iOS wording; + the field carries `ProfileEditBio`. + +## Android vs iOS + +All four journeys share their file names and journey names with `bitkit-ios`. Every step names +identifiers as testTags rather than iOS ids, drops the iOS `predicate exists` wait argument and runs +`adb` instead of `xcrun simctl`; the differences below are the rest. + +- **Cached profile header while loading.** While the session is still restoring, Android shows a + bare spinner on Profile (`ProfileLoading`), as iOS does, so one step differs. Android adds a + screenshot check that the cached header shows the profile's avatar: the avatar comes from + the Pubky image disk cache that synonymdev/bitkit-android#1399 adds, so the check is what shows + it works without the network. iOS shows the cached avatar too but does not check it. +- **Pubky ring choice rows.** Every row is `PubkyChoiceIdentity` and every lookup spinner + `PubkyChoiceIdentityLookup`, where iOS gives each its own `PubkyChoiceRing_` and + `PubkyChoiceRingLookup_`, so the steps tell rows apart by their key caption. The row + avatar has a testTag here (`PubkyContactAvatar`) and none on iOS. Three steps differ: the profile + button always opens the next screen, so it needs no second tap; a disabled row ignores taps, so + the disabled check taps another row and expects nothing to change, where iOS reads the rows + missing from the snapshot's Targets list; and the import overview has no testTag, so the last + step names its "Import" title. On Android the other rows' lookups keep running during an + adoption, where iOS stops them; the journey checks the rows only after every lookup has finished, + so it does not depend on that. +- **Contact import after leaving.** `contact-import-after-leaving.xml` has the same file, journey + name and steps on both platforms, and the steps differ only in identifiers. Android tags every + Ring row `PubkyChoiceIdentity`, where iOS tags each `PubkyChoiceRing_`, and the import + overview's profile and friend count have no testTag here (iOS: `ContactImportOverviewProfile`, + `ContactImportOverviewSummary`), so the journey names the "Import" title and the "N friends" text + instead. synonymdev/bitkit-android#1399 adds `ContactImportOverviewImportAll`, the only contact + import testTag the journeys use; Back is the shared `NavigationBack`. See the Identifiers table + in [`journeys/README.md`](../README.md#identifiers). Both platforms save the import only to the + device. +- **Contacts list loading.** `contacts-list-loading.xml` has the same file, journey name and steps + on both platforms; only the relaunch commands differ. + +## Test tags used + +- Home: `ProfileButton`. +- Profile, cached: `ProfileCachedHeader`, `ProfileCachedName`. +- Profile, loaded: `ProfileViewName`, `ProfileEdit`, `ProfileCopy`, `ProfileShare`, `ProfileQRCode`, + `ProfileAddTag`; failed load `ProfileRetry`. +- Profile intro: `ProfileIntro`, `ProfileIntro-button`. +- Pubky choice: `PubkyChoiceIdentity` for each Ring row, `PubkyChoiceIdentityLookup` for a row's + lookup spinner and `PubkyContactAvatar` for the avatar that replaces it, `PubkyChoiceCreate` when + Ring has none. +- Contact import: `ContactImportOverviewImportAll` and Back `NavigationBack`; Pay Contacts: + `PayContactsContinue`. +- Contacts: `HeaderMenu`, `DrawerContacts`, the contacts intro's `ContactsIntro-button`, + `Contact_` for each row (the full key with its `pubky` prefix); contact screen + `ContactViewName`, `ContactViewNotes`, `ContactEdit`, `NavigationBack`; edit form + `ProfileEditBio`, `ProfileEditCancel`. diff --git a/journeys/pubky-profile/cached-profile-header.xml b/journeys/pubky-profile/cached-profile-header.xml new file mode 100644 index 0000000000..8282751fcd --- /dev/null +++ b/journeys/pubky-profile/cached-profile-header.xml @@ -0,0 +1,40 @@ + + + Verifies that opening Profile straight after a relaunch, while the signed-in profile is still + loading, shows the cached name read-only. The edit, copy, share, QR code and tag controls are + absent. The full profile then replaces the cached header in place. + + Precondition: an onboarded dev wallet with Paykit enabled (the default), PIN off, and an active + Pubky profile with a name and an avatar, created in Bitkit or adopted from Pubky Ring. Profile + must have loaded fully once on this install, so the cache records which key the name and avatar + belong to; the first steps do that. + + A warm network can finish the load before the first inspection, so an "already loaded" result + repeats the relaunch once instead of failing. To keep the load running on an emulator, run adb + emu network delay gprs and adb emu network speed edge before the first step, and adb emu network + delay none and adb emu network speed full after the last; a physical device needs another way to + slow its connection. If the load fails instead, the cached header gives way to the retry state + (testTag "ProfileRetry"), which is not what this journey covers: relax the throttle and repeat. + + While the session is still restoring, Profile shows a spinner under the "Profile" title with no + name (testTag "ProfileLoading"), as iOS shows a bare initialization spinner, and the cached + header follows once the session is back. One step differs from iOS: Android also checks the + cached avatar from a screenshot, because it comes from the Pubky image disk cache, which shows + it without the network. See README.md. + + + Tap the header profile button (testTag "ProfileButton") + Wait for the full profile name (testTag "ProfileViewName") and note the name it shows + Run `adb shell am force-stop to.bitkit.dev` + Run `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity` + If the PIN screen appears, unlock the wallet + As soon as the home screen shows, tap the header profile button (testTag "ProfileButton") + If a spinner under the "Profile" title with no name (testTag "ProfileLoading") shows first, wait for it to give way. That is the session restore, which has to finish before the cached header can show + Verify the cached profile header (testTag "ProfileCachedHeader") is visible and its name (testTag "ProfileCachedName") matches the name noted before the relaunch. If the full profile name (testTag "ProfileViewName") is already showing, the load finished first: report "already loaded" and repeat from the force-stop step once rather than failing + Verify the cached header is read-only: edit (testTag "ProfileEdit"), copy (testTag "ProfileCopy"), share (testTag "ProfileShare"), the QR code (testTag "ProfileQRCode") and add tag (testTag "ProfileAddTag") are all absent + Take a screenshot and verify the cached header shows the profile's avatar, not the placeholder icon + Wait for the full profile name (testTag "ProfileViewName") + Verify the cached profile header (testTag "ProfileCachedHeader") is gone and the full profile name (testTag "ProfileViewName") matches the cached name + Verify edit (testTag "ProfileEdit") and the QR code (testTag "ProfileQRCode") are visible + + diff --git a/journeys/pubky-profile/contact-import-after-leaving.xml b/journeys/pubky-profile/contact-import-after-leaving.xml new file mode 100644 index 0000000000..682bae8e85 --- /dev/null +++ b/journeys/pubky-profile/contact-import-after-leaving.xml @@ -0,0 +1,34 @@ + + + A contact import keeps saving after you leave the import overview, and when it finishes it leaves + you where you went instead of opening Pay Contacts. Saving the prepared follows without looking + them up again is covered by journeys/contacts/import-all-contacts.xml and + import-selected-contacts.xml. + + Precondition (not a listed capability): Pubky Ring (package to.pubky.ring) installed on the same + device and signed with this repository's app/debug.keystore, as for the pubky ring choice + rows journey, with an identity that follows many pubkys on pubky.app (62, as in + import-all-contacts.xml), so the import takes long enough to leave. An onboarded dev wallet with + Paykit enabled (the default), PIN off and no active Pubky identity in Bitkit. The journey adopts + that identity and saves its follows as contacts, so sign out of it in Bitkit before running the + journey again. See README.md. + + The import saves only to the device and can finish before Back is tapped; if Pay Contacts opens + first, report "already imported" rather than failing. + + Android differs from iOS only in identifiers. Every Ring row carries the same testTag, so tell + the rows apart by their key caption, and the overview's profile and friend count carry no + testTag, so read the "Import" title and the count from its "N friends" text; see the Identifiers + table in journeys/README.md. + + + Tap the header profile button (testTag "ProfileButton"); if the profile intro (testTag "ProfileIntro") shows, tap Continue (testTag "ProfileIntro-button") + Tap the Ring row (testTag "PubkyChoiceIdentity", told apart by its key caption) of the identity that follows many pubkys + Wait for the contact import overview (no testTag; titled "Import") and note the number of friends the summary (no testTag; "N friends") shows + Tap Import All (testTag "ContactImportOverviewImportAll") and straight away tap Back (testTag "NavigationBack"). If Pay Contacts (testTag "PayContactsContinue") shows instead, the import finished first: report "already imported" and stop + Verify Home shows (testTag "ProfileButton") + Wait ten seconds and verify Home still shows and Pay Contacts (testTag "PayContactsContinue") has not opened + Tap the menu button (testTag "HeaderMenu"), then Contacts (testTag "DrawerContacts"); if the contacts intro shows, tap its continue button + Verify Contacts lists one row (testTag "Contact_<pubky>") for each of the friends the overview counted + + diff --git a/journeys/pubky-profile/contacts-list-loading.xml b/journeys/pubky-profile/contacts-list-loading.xml new file mode 100644 index 0000000000..89f6301525 --- /dev/null +++ b/journeys/pubky-profile/contacts-list-loading.xml @@ -0,0 +1,37 @@ + + + Contacts shows the saved contacts as soon as it opens after a relaunch, under the names they were + saved with, and fills in each contact's profile name and avatar as its lookup finishes. It no + longer shows a spinner over an empty list until every lookup has finished, and a lookup that + fails leaves the row as it is. Within the session, reopening Contacts shows the profiles already + found straight away. Opening a contact whose profile has not loaded yet looks it up at once, and + its edit form shows the contact's published bio rather than an empty one, so saving an edit keeps + it. + + Precondition: an onboarded dev wallet with Paykit enabled (the default) and a Pubky identity with + at least five saved contacts, at least one of them with a published profile name and bio and one + with no published profile. Importing such follows with journeys/contacts/import-all-contacts.xml + leaves exactly that. + + A warm network can resolve every profile before the first inspection; report "already resolved" + rather than failing. To slow the lookups on an emulator, run adb emu network delay gprs and adb + emu network speed edge before the first step, and adb emu network delay none and adb emu network + speed full after the last. If the Pubky session is still restoring when Contacts is tapped, the + menu opens the contacts intro or the Pubky choice screen instead, and the choice screen moves on + to Profile by itself once the session is back: return to the home screen, wait a moment and open + Contacts from the menu again. That is the session restore, not the contacts list. The edit form + labels the bio field "Bio". See README.md. + + + Run `adb shell am force-stop to.bitkit.dev` + Run `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity` + If the PIN screen appears, unlock the wallet + Tap the menu button (testTag "HeaderMenu"), then Contacts (testTag "DrawerContacts") + Verify the list shows one row (testTag "Contact_<pubky>") per saved contact, each with a name, and no spinner over an empty list. A contact whose profile has not loaded yet shows the name it was saved with, or its truncated key + Tap the row of a contact with a published bio straight away and verify its contact screen shows that bio (testTag "ContactViewNotes") within a few seconds. Tap Edit (testTag "ContactEdit") and verify the notes field shows the same bio, then tap Cancel (testTag "ProfileEditCancel") and go back (testTag "NavigationBack") + Wait up to a minute and verify each contact with a published profile now shows its profile name, and its avatar if it has one. If every row already did, report "already resolved" + Verify a contact with no published profile still shows the name it had when the list opened + Tap a contact's row and verify its contact screen opens with that contact's name, then go back + Verify Contacts shows every row straight away with the profile names it showed before, with no row back on a truncated key + + diff --git a/journeys/pubky-profile/ring-choice-rows.xml b/journeys/pubky-profile/ring-choice-rows.xml new file mode 100644 index 0000000000..4de7113a41 --- /dev/null +++ b/journeys/pubky-profile/ring-choice-rows.xml @@ -0,0 +1,50 @@ + + + Verifies that the Pubky Ring choice screen lists every Ring identity straight away under its + truncated key and fills each name in as its profile lookup finishes. While a row's lookup runs, + a spinner takes the place of its avatar, so a row still looking up can be told apart from a row + whose identity has no profile. It also verifies that tapping a row shows a spinner on that row + only and disables every row until adoption finishes. + + Precondition (not a listed capability): Pubky Ring (package to.pubky.ring) installed on the same + device with at least two identities, at least one of them with a published profile name. + Bitkit reads Ring's shared pubky provider only when Ring is signed with the same key as the + Bitkit build, so a dev build needs a Ring build signed with this repository's + app/debug.keystore; with any other signature the choice screen offers only "Create profile with + Bitkit" (testTag "PubkyChoiceCreate"). Also needed: an onboarded dev wallet with Paykit enabled + (the default) and no active Pubky identity in Bitkit. The named identity is the one with a + published name. Tapping its row signs in with it, so sign out of it in Bitkit before running the + journey again. + + A warm network can finish the lookups and the adoption before the screen is inspected; the steps + report that rather than failing. To catch them on an emulator, run adb emu network delay gprs and + adb emu network speed edge before the first step, and adb emu network delay none and adb emu + network speed full after the last; a physical device needs another way to slow its connection. + + Android differs from iOS in identifiers and in three steps. Every row carries the same testTag, + and so does every lookup spinner, so tell the rows apart by their key caption; the avatar carries + testTag "PubkyContactAvatar" and the adoption spinner none, as the Identifiers table in + journeys/README.md records. On Android both spinners' accessibility description reads "Loading + your profile…". A disabled row ignores taps, so the disabled check taps another row rather than + reading the layout dump. The profile button always opens the next screen, so it needs no second + tap, and the import overview has no testTag, so it is named by its "Import" title. See README.md. + + + Run `adb shell am force-stop to.bitkit.dev`, then `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity`, so no Ring row profile is remembered from earlier in the session + If the PIN screen appears, unlock the wallet + Tap the header profile button (testTag "ProfileButton") + If the Profile intro (testTag "ProfileIntro") appears, tap Continue (testTag "ProfileIntro-button") + Verify the choice screen shows one row per Ring identity (testTag "PubkyChoiceIdentity") and no create option (testTag "PubkyChoiceCreate") + Verify every row is already tappable and captioned with its truncated key in capitals: the key's first four and last four characters around "...". A row whose name has not arrived also shows that truncated key as its title. If every name is already showing, the lookups finished first: report "already resolved" rather than failing + Verify each row whose lookup is still running shows its key icon and a spinner (testTag "PubkyChoiceIdentityLookup") in place of its avatar. If every row already shows its avatar, the lookups finished first: report "already resolved" rather than failing + Wait for the row for the named identity (testTag "PubkyChoiceIdentity", told apart by its key caption) to show its published name as its title + Verify that row keeps its truncated key as its caption, and any row whose identity has no published name still shows its truncated key as its title + Wait up to a minute for each row's lookup spinner (testTag "PubkyChoiceIdentityLookup") to be gone. The lookup for an identity with no profile can take several seconds to give up + Verify no row shows the lookup spinner (testTag "PubkyChoiceIdentityLookup") any more: every row shows its avatar (testTag "PubkyContactAvatar"), either its profile picture or the first character of its title. The avatar has no text, so check it from a screenshot + Tap the row for the named identity (testTag "PubkyChoiceIdentity", told apart by its key caption) + Straight away, capture a layout dump and a screenshot before adoption finishes + Verify every Ring row is disabled while the identity is adopted. A disabled row ignores taps, so tap a different row (testTag "PubkyChoiceIdentity") and verify nothing changes: the spinner stays on the tapped row and no second adoption starts. If adoption finished before the capture, report "already adopted" rather than failing + Verify from the screenshot that only the tapped row shows a spinner, in place of its key icon, while it keeps its avatar and every other row keeps its key icon and its name or truncated key. The key-icon spinner has no testTag, so a layout dump cannot settle this. If adoption finished before the capture, report "already adopted" rather than failing + Verify the choice screen gives way to the contact import overview (no testTag; titled "Import") or Pay Contacts (testTag "PayContactsContinue") + +