diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index 43e27f3409..5460a40af1 100644 --- a/app/src/main/java/to/bitkit/data/PubkyStore.kt +++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt @@ -28,8 +28,14 @@ class PubkyStore @Inject constructor( store.updateData(transform) } + /** Clears the session's data but keeps the migration history, so a profile deleted on pubky.app stays deleted. */ suspend fun reset() { - store.updateData { PubkyStoreData() } + store.updateData { + PubkyStoreData( + pubkyAppProfileOwners = it.pubkyAppProfileOwners, + pubkyAppAvatarPending = it.pubkyAppAvatarPending, + ) + } } } @@ -41,6 +47,10 @@ data class PubkyStoreData( val cachedName: String? = null, val cachedImageUri: String? = null, val contactProfileOverrides: Map = emptyMap(), + /** Identities Bitkit has seen a pubky.app profile for, so a profile deleted there is not moved back from Bitkit. */ + val pubkyAppProfileOwners: Set = emptySet(), + /** Identities whose Bitkit avatar is still to be moved to pubky.app, retried on each profile load. */ + val pubkyAppAvatarPending: Set = emptySet(), ) { fun cachedProfile(): PubkyCachedProfile? = cachedProfileOwner?.let { PubkyCachedProfile(publicKey = it, name = cachedName.orEmpty(), imageUri = cachedImageUri) diff --git a/app/src/main/java/to/bitkit/models/PubkyProfile.kt b/app/src/main/java/to/bitkit/models/PubkyProfile.kt index d5e846c802..214f48a309 100644 --- a/app/src/main/java/to/bitkit/models/PubkyProfile.kt +++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt @@ -10,6 +10,18 @@ import com.synonym.paykit.PubkyProfile as SdkPubkyProfile private val pubkyProfileJson = Json { ignoreUnknownKeys = true } +/** pubky.app profile name limits from pubky-app-specs. */ +const val PROFILE_NAME_MIN_LENGTH = 3 +const val PROFILE_NAME_MAX_LENGTH = 50 +const val PROFILE_LINKS_MAX_COUNT = 5 + +/** pubky.app counts code points, not UTF-16 chars. */ +fun isValidProfileName(name: String) = name.trim().let { it.codePointCount(0, it.length) } in + PROFILE_NAME_MIN_LENGTH..PROFILE_NAME_MAX_LENGTH + +fun String.takeCodePoints(max: Int) = + if (codePointCount(0, length) <= max) this else substring(0, offsetByCodePoints(0, max)) + @Immutable data class PubkyProfileLink(val label: String, val url: String) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index c543d12d39..addc9a8c5b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -6,8 +6,11 @@ import androidx.core.graphics.scale import coil3.ImageLoader import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate +import com.synonym.paykit.PaykitException import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim +import com.synonym.paykit.PubkyProfileSnapshot +import com.synonym.paykit.PubkyProfileUpdate import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post @@ -69,12 +72,15 @@ import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 +import to.bitkit.models.takeCodePoints import to.bitkit.services.PaykitReadLane import to.bitkit.services.PaykitReadTimeoutError import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.ByteArrayOutputStream +import java.net.URI +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -84,6 +90,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime +import com.synonym.paykit.PubkyProfileLink as SdkPubkyProfileLink sealed class PubkyContactError(message: String) : AppError(message) { data object AlreadyExists : PubkyContactError("Contact already exists") @@ -110,6 +117,29 @@ private fun Throwable.isPaykitReadTimeout(): Boolean = data object PubkyAlreadySignedInError : AppError("Already signed in") +/** + * The profile an edit starts from and the pubky.app revision it was read at; null when none exists yet. [tagsUnread] is + * set when the Paykit copy could not be read, so the profile's tags are not the stored ones and a save keeps those. + */ +data class ProfileEditBase(val profile: PubkyProfile?, val revision: String?, val tagsUnread: Boolean = false) + +/** The pubky.app profile changed since Bitkit loaded it, so the save was not applied. */ +class PubkyProfileChangedError(cause: Throwable) : AppError("Profile changed in pubky.app", cause = cause) + +/** The other fields were saved and [base] is where a retry starts from, but the tag change was not. */ +class PubkyProfileTagsNotSavedError(val base: ProfileEditBase, cause: Throwable? = null) : + AppError("Profile tags were not saved", cause = cause) + +private class PubkyProfileNotFoundError : AppError("Profile not found") + +private class SavedProfile(val profile: PubkyProfile, val revision: String, val tagError: Throwable?) + +private class StoredCopy(val tags: List?, val image: String?) + +private class FittedProfile(val name: String, val bio: String, val links: List) + +private class RestoreBase(val publicKey: String, val profile: PubkyProfile, val revision: String?) + /** Whether a Pubky identity is ready to use once restores already under way have finished. */ enum class PubkyIdentityReadiness { /** A session is active. */ @@ -160,6 +190,17 @@ class PubkyRepo @Inject constructor( /** Delay after a deferred restore before another attempt may begin. */ private val DEFERRED_RESTORE_INTERVAL = 5.seconds + + // pubky.app profile limits (pubky-app-specs), applied when moving a Bitkit profile there. + private const val PUBKY_APP_NAME_MAX_LENGTH = 50 + private const val PUBKY_APP_BIO_MAX_LENGTH = 160 + private const val PUBKY_APP_LINKS_MAX_COUNT = 5 + private val ISO_DATE = Regex("""\d{4}-\d{2}-\d{2}""") + private val WEB_SCHEMES = setOf("http", "https", "ws", "wss", "ftp") + private val IPV4 = Regex("""\d{1,3}(\.\d{1,3}){3}""") + private const val PUBKY_APP_LINK_TITLE_MAX_LENGTH = 100 + private const val PUBKY_APP_LINK_URL_MAX_LENGTH = 300 + private const val PUBKY_APP_AVATAR_MAX_BYTES = 5_242_880uL } private val scope = appScope(ioDispatcher, TAG) @@ -185,6 +226,27 @@ class PubkyRepo @Inject constructor( private val adoptedSourceCheckMutex = Mutex() private val adoptionMutex = Mutex() private val profileWriteGeneration = AtomicLong(0L) + + // Held by each own-profile write and by profile deletion, so a deletion waits for the writes under way. The + // deletion moves the generation on, so a load or save that started before or during it writes nothing after. + private val profileWriteMutex = Mutex() + private val profileDeletionGeneration = AtomicLong(0L) + + // Moved on when a save starts and when it ends. A load writes the Paykit copy back only when no save runs and none + // ran since it read the copy, so it never writes back tags a save replaced. + private val profileSaveEpoch = AtomicLong(0L) + private val profileSavesRunning = AtomicInteger(0) + + /** Revision of the loaded pubky.app profile, which a save must still match; null when none exists. */ + @Volatile + private var ownProfileRevision: String? = null + + /** Whether the last profile load could not read the Paykit copy, so the shown tags are not the stored ones. */ + @Volatile + private var tagsUnavailable = false + + @Volatile + private var restoreBase: RestoreBase? = null private val signInGeneration = AtomicLong(0L) private val storedIdentityRevision = AtomicLong(0L) private val readOnlyProfileGeneration = AtomicLong(0L) @@ -807,10 +869,9 @@ class PubkyRepo @Inject constructor( try { runSuspendCatching { withContext(ioDispatcher) { - resolveContactProfile(signIn.publicKey, retry = true).getOrThrow() - ?: throw AppError("Profile not found") + loadOwnProfile(signIn.publicKey) ?: throw PubkyProfileNotFoundError() } - }.onSuccess { loadedProfile -> + }.onSuccess { (loadedProfile, revision) -> var isCurrent = false _profile.update { isCurrent = isCurrentLoad() @@ -820,10 +881,17 @@ class PubkyRepo @Inject constructor( Logger.debug("Skipped stale profile load for '${redacted(signIn.publicKey)}'", context = TAG) return@onSuccess } + ownProfileRevision = revision invalidateReadOnlyProfile() cacheMetadata(loadedProfile, isCurrentLoad) }.onFailure { Logger.error("Failed to load profile", it, context = TAG) + // A profile deleted on pubky.app is set up again rather than left unreachable. + if (it is PubkyProfileNotFoundError && isCurrentLoad()) { + _profile.update { null } + ownProfileRevision = null + settingsStore.setPubkyProfileSetupPending(true) + } } completedProfileSignInGeneration = signIn.generation completedProfileWriteGeneration = writeGeneration @@ -846,7 +914,8 @@ class PubkyRepo @Inject constructor( val secret = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) ?: adoptedSecretKeyHex() ?: return@runSuspendCatching null val owner = pubkyService.publicKeyFromSecret(secret).ensurePubkyPrefix() - resolveContactProfile(owner, retry = false).getOrThrow() + pubkyService.fetchPubkyProfileWithRevision(owner) + ?.let { PubkyProfile.fromPubkyProfile(owner, it.profile) } }.onSuccess { loaded -> synchronized(readOnlyProfileLock) { if (isCurrentLoad()) _readOnlyProfile.update { loaded } @@ -860,8 +929,27 @@ class PubkyRepo @Inject constructor( } } - suspend fun fetchRemoteProfile(publicKey: String): Result = - resolveContactProfile(publicKey, retry = true) + suspend fun fetchRemoteProfile(publicKey: String): Result = runSuspendCatching { + withContext(ioDispatcher) { + val prefixedKey = publicKey.ensurePubkyPrefix() + val paykitRead = runSuspendCatching { pubkyService.fetchPaykitProfile(prefixedKey) } + .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } + val paykitData = paykitRead.getOrNull()?.let { PubkyProfileData.fromPaykitProfile(it.profile) } + // Cleared before the read, so a failed lookup never leaves an earlier visit's base to save against. + restoreBase = null + val snapshot = readRetryingOnce(prefixedKey) { pubkyService.fetchPubkyProfileWithRevision(prefixedKey) } + if (snapshot == null) { + paykitRead.getOrThrow() + return@withContext paykitData?.toPubkyProfile(prefixedKey)?.also { + restoreBase = RestoreBase(prefixedKey, it, revision = null) + } + } + tagsUnavailable = paykitRead.isFailure + PubkyProfile.fromPubkyProfile(prefixedKey, snapshot.profile).copy(tags = paykitData?.tags.orEmpty()).also { + restoreBase = RestoreBase(prefixedKey, it, snapshot.revision) + } + } + } /** * Resolves [publicKey]'s profile once, without retrying, for display only. A null or failed result is not proof @@ -899,8 +987,8 @@ class PubkyRepo @Inject constructor( return@withLock runSuspendCatching { withContext(ioDispatcher) { val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" } - val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) - finishIdentityCreation(publicKey, name, bio, links, tags, imageUrl) + val saved = publishIdentityProfile(publicKey, name, bio, links, tags, avatarBytes) + finishIdentityCreation(publicKey, saved) } } } @@ -913,9 +1001,9 @@ class PubkyRepo @Inject constructor( val publicKeyZ32 = _publicKey.value ?: createLocalIdentitySession { shouldRevokeSessionOnFailure = true } - val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) + val saved = publishIdentityProfile(publicKeyZ32, name, bio, links, tags, avatarBytes) shouldRevokeSessionOnFailure = false - finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl) + finishIdentityCreation(publicKeyZ32, saved) } } if (creationResult.isFailure) revokeIncompleteIdentitySessionIfNeeded(shouldRevokeSessionOnFailure) @@ -959,35 +1047,32 @@ class PubkyRepo @Inject constructor( } private suspend fun publishIdentityProfile( + publicKey: String, name: String, bio: String, links: List, tags: List, avatarBytes: ByteArray?, - ): String? { - val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } - writeProfile(name, bio, links, tags, imageUrl) - return imageUrl + ): SavedProfile { + // A restore saves against the pubky.app profile its form was filled from; without one it creates the profile. + val restore = restoreBase?.takeIf { it.publicKey == publicKey } + val imageUrl = avatarBytes?.let { uploadAvatar(it, publicKey).getOrNull() } ?: restore?.profile?.imageUrl + val base = restore?.profile?.takeIf { restore.revision != null } + val saved = runSuspendCatching { + writeProfile(publicKey, name, bio, links, tags, imageUrl, base, expectedRevision = restore?.revision) + }.getOrElse { + if (!it.isConcurrentUpdate()) throw it + throw PubkyProfileChangedError(it) + } + restoreBase = null + saved.tagError?.let { Logger.warn("Created profile without its tag change", it, context = TAG) } + return saved } - private suspend fun finishIdentityCreation( - publicKey: String, - name: String, - bio: String, - links: List, - tags: List, - imageUrl: String?, - ): String { - val createdProfile = PubkyProfile( - publicKey = publicKey, - name = name, - bio = bio, - imageUrl = imageUrl, - links = links, - tags = tags, - status = null, - ) + private suspend fun finishIdentityCreation(publicKey: String, saved: SavedProfile): String { + val createdProfile = saved.profile startSignIn(publicKey) + ownProfileRevision = saved.revision setProfile(createdProfile) cacheMetadata(createdProfile) settingsStore.setPubkyProfileSetupPending(false) @@ -1024,41 +1109,67 @@ class PubkyRepo @Inject constructor( return clearedLocalState } - suspend fun uploadAvatar(imageBytes: ByteArray): Result = runSuspendCatching { + suspend fun uploadAvatar(imageBytes: ByteArray, publicKey: String? = null): Result = runSuspendCatching { withContext(ioDispatcher) { requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } + val identity = requireNotNull(publicKey ?: _publicKey.value) { "No public key available" } val compressed = compressAvatar(imageBytes) - pubkyService.uploadProfileAvatar(compressed, contentType = "image/jpeg") + withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(compressed, "image/jpeg", identity) } } } + /** The profile and pubky.app revision an edit starts from; its save is checked against that revision. */ + fun profileEditBase() = ProfileEditBase(_profile.value, ownProfileRevision, tagsUnavailable) + + suspend fun saveProfile( + name: String, + bio: String, + links: List, + tags: List, + imageUrl: String?, + ): Result = saveProfile(name, bio, links, tags, imageUrl, profileEditBase()) + suspend fun saveProfile( name: String, bio: String, links: List, tags: List, imageUrl: String?, + editBase: ProfileEditBase, ): Result = runSuspendCatching { withContext(ioDispatcher) { requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } - writeProfile(name, bio, links, tags, imageUrl) - val pk = requireNotNull(_publicKey.value) { "No public key available" } - val profile = PubkyProfile( - publicKey = pk, - name = name, - bio = bio, - imageUrl = imageUrl ?: _profile.value?.imageUrl, - links = links, - tags = tags, - status = _profile.value?.status, - ) - setProfile(profile) - cacheMetadata(profile) + val signIn = requireNotNull(currentSignIn()) { "No public key available" } + val pk = signIn.publicKey + val saved = runSuspendCatching { + writeProfile( + pk, + name, + bio, + links, + tags, + imageUrl, + base = editBase.profile.takeIf { editBase.revision != null }, + expectedRevision = editBase.revision, + tagsUnread = editBase.tagsUnread, + ) + }.getOrElse { + if (!it.isConcurrentUpdate()) throw it + ownProfileRevision = null + scope.launch { loadProfile() } + throw PubkyProfileChangedError(it) + } + // A session change while the save ran leaves the new session's profile alone. + if (!isCurrent(signIn)) return@withContext + setProfile(saved.profile) + ownProfileRevision = saved.revision + cacheMetadata(saved.profile) notifyBackupStateChanged() + saved.tagError?.let { throw it } } } @@ -1079,13 +1190,21 @@ class PubkyRepo @Inject constructor( } clearSessionContactProfiles() deleteAllContacts() - runSuspendCatching { - pubkyService.deletePaykitProfile() - }.getOrElse { - if (!it.isMissingPubkyData()) { - throw it + profileWriteMutex.withLock { + profileDeletionGeneration.incrementAndGet() + try { + if (!isRingAdoptedIdentity()) deletePubkyAppProfile() + runSuspendCatching { + pubkyService.deletePaykitProfile() + }.getOrElse { + if (!it.isMissingPubkyData()) { + throw it + } + Logger.info("Continuing sign out, bitkit profile storage already missing", context = TAG) + } + } finally { + profileDeletionGeneration.incrementAndGet() } - Logger.info("Continuing sign out, bitkit profile storage already missing", context = TAG) } profileWriteGeneration.incrementAndGet() } @@ -1093,6 +1212,18 @@ class PubkyRepo @Inject constructor( signOut().getOrThrow() } + private suspend fun deletePubkyAppProfile() { + val publicKey = _publicKey.value ?: return + val known = ownProfileRevision + val revision = known ?: pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision ?: return + runSuspendCatching { withPubkyAppScope { pubkyService.deletePubkyProfile(revision, publicKey) } }.getOrElse { + if (known == null || !it.isConcurrentUpdate()) throw it + val current = pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision + if (current != null) withPubkyAppScope { pubkyService.deletePubkyProfile(current, publicKey) } + } + ownProfileRevision = null + } + private suspend fun deleteAllContacts() { val records = runSuspendCatching { pubkyService.contactRecords() @@ -1119,22 +1250,307 @@ class PubkyRepo @Inject constructor( } private suspend fun writeProfile( + publicKey: String, name: String, bio: String, links: List, tags: List, imageUrl: String?, + base: PubkyProfile?, + expectedRevision: String?, + tagsUnread: Boolean = false, + ): SavedProfile { + profileSaveEpoch.incrementAndGet() + profileSavesRunning.incrementAndGet() + try { + return writeProfileOnce(publicKey, name, bio, links, tags, imageUrl, base, expectedRevision, tagsUnread) + } finally { + profileSavesRunning.decrementAndGet() + profileSaveEpoch.incrementAndGet() + } + } + + private suspend fun writeProfileOnce( + publicKey: String, + name: String, + bio: String, + links: List, + tags: List, + imageUrl: String?, + base: PubkyProfile?, + expectedRevision: String?, + tagsUnread: Boolean, + ): SavedProfile { + val generation = profileDeletionGeneration.get() + // Unread when the edit began or now: either way the form's tags are not the stored ones. + val copyUnread = tagsUnread || tagsUnavailable + // A profile written without a base is fitted to pubky.app's limits, as a move is. + val fitted = if (base == null) fitted(name, bio, links) else null + val links = fitted?.links ?: links.map { + PubkyProfileLink( + label = it.label.singleLine(), + url = it.url.toPubkyAppLinkUrl() ?: it.url + ) + } + val bio = fitted?.bio ?: bio.singleLine() + val name = fitted?.name ?: name + // A Bitkit avatar still on Paykit storage is moved by a later load rather than written to pubky.app as is. + val paykitAvatar = imageUrl?.takeIf { it.isPaykitStorage() } + val update = PubkyProfileUpdate( + name = name.singleLine().takeIf { it != base?.name }, + bio = bio.takeIf { base == null || it != base.bio }, + image = when { + paykitAvatar != null -> null + base == null -> imageUrl + imageUrl == base.imageUrl -> null + else -> imageUrl.orEmpty() + }, + links = links.takeIf { base == null || it != base.links } + ?.map { SdkPubkyProfileLink(title = it.label, url = it.url) }, + ) + val snapshot = profileWrite(generation) { + withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision, publicKey) } + } + ownProfileRevision = snapshot.revision + val baseTags = base?.tags.orEmpty() + val stored = storedCopy(publicKey, copyUnread) + val wasPending = publicKey in pubkyStore.data.first().pubkyAppAvatarPending + // The form could not show an avatar still to move while the copy was unread, so it stays pending. + val keepPending = snapshot.profile.image == null && copyUnread && wasPending + val pendingAvatar = when { + snapshot.profile.image != null -> null + paykitAvatar != null -> paykitAvatar + keepPending -> stored?.image?.takeIf { it.isPaykitStorage() } + else -> null + } + val avatarPending = pendingAvatar != null || keepPending + pubkyStore.update { + val keys = it.pubkyAppAvatarPending + val pending = if (avatarPending) keys + publicKey else keys - publicKey + it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners + publicKey, pubkyAppAvatarPending = pending) + } + val keptTags = stored?.let { copy -> copy.tags?.let { it + tags.filterNot { tag -> tag in it } } ?: tags } + val profile = PubkyProfile.fromPubkyProfile(publicKey, snapshot.profile).let { + it.copy(tags = keptTags ?: baseTags, imageUrl = pendingAvatar ?: it.imageUrl) + } + // Tags live only in the Paykit copy, so a changed tag that it does not take is reported. + val copyData = profile.toProfileData().toPaykitProfile() + val copyError = keptTags?.let { + runSuspendCatching { profileWrite(generation) { pubkyService.publishPaykitProfile(copyData, publicKey) } } + .onSuccess { tagsUnavailable = false } + .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } + .exceptionOrNull() + } + val saved = if (keptTags != null && copyError == null) profile else profile.copy(tags = baseTags) + val tagsLost = (keptTags == null || copyError != null) && tags != baseTags + val tagError = PubkyProfileTagsNotSavedError( + ProfileEditBase(saved, snapshot.revision, tagsUnread = copyUnread), + copyError + ).takeIf { tagsLost } + return SavedProfile(saved, snapshot.revision, tagError) + } + + /** + * What a save keeps from the Paykit copy after the last load could not read it, as the form then lacked its tags + * and the avatar still to move: empty when that load read it, null while it still cannot be read. + */ + private suspend fun storedCopy(publicKey: String, copyUnread: Boolean): StoredCopy? { + if (!copyUnread) return StoredCopy(tags = null, image = null) + val record = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) }.getOrElse { return null } + val data = record?.let { PubkyProfileData.fromPaykitProfile(it.profile) } + return StoredCopy(tags = data?.tags.orEmpty(), image = data?.image) + } + + /** [name], [bio] and [links] fitted to pubky.app's limits: one line, cut to length, at most five valid links. */ + private fun fitted(name: String, bio: String, links: List) = FittedProfile( + name = name.singleLine().takeCodePoints(PUBKY_APP_NAME_MAX_LENGTH), + bio = bio.singleLine().takeCodePoints(PUBKY_APP_BIO_MAX_LENGTH), + links = links + .mapNotNull { link -> + val title = link.label.singleLine().takeIf { it.isNotEmpty() } ?: return@mapNotNull null + val url = link.url.toPubkyAppLinkUrl() ?: return@mapNotNull null + PubkyProfileLink(label = title.takeCodePoints(PUBKY_APP_LINK_TITLE_MAX_LENGTH), url = url) + } + .take(PUBKY_APP_LINKS_MAX_COUNT), + ) + + private suspend fun loadOwnProfile(publicKey: String): Pair? { + val generation = profileDeletionGeneration.get() + val saveEpoch = profileSaveEpoch.get() + val paykitRead = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) } + .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } + tagsUnavailable = paykitRead.isFailure + val paykitData = paykitRead.getOrNull()?.let { PubkyProfileData.fromPaykitProfile(it.profile) } + val fetched = readRetryingOnce(publicKey) { pubkyService.fetchPubkyProfileWithRevision(publicKey) } + if (fetched == null) paykitRead.getOrThrow() + val stored = pubkyStore.data.first() + val hadPubkyAppProfile = publicKey in stored.pubkyAppProfileOwners + val migrating = fetched == null && !hadPubkyAppProfile + var snapshot = fetched ?: paykitData?.takeIf { migrating }?.let { migrateToPubkyApp(publicKey, it, generation) } + // While the move cannot finish, the Bitkit profile is shown and the next save creates the pubky.app profile. + snapshot ?: return paykitData?.takeIf { migrating }?.let { it.toPubkyProfile(publicKey) to null } + val pendingAvatar = paykitData?.image?.takeIf { it.isPaykitStorage() } + var retryingAvatar = fetched != null && publicKey in stored.pubkyAppAvatarPending + val avatarMoveSettled = fetched?.profile?.image != null || (paykitRead.isSuccess && pendingAvatar == null) + if (retryingAvatar && avatarMoveSettled) { + // An avatar set on pubky.app meanwhile wins, and only an avatar on Paykit storage is moved. An unread copy + // settles nothing, so the move is retried on a later load. + pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - publicKey) } + } + if (pendingAvatar == null || avatarMoveSettled) retryingAvatar = false + if (retryingAvatar) { + snapshot = moveAvatar(publicKey, checkNotNull(pendingAvatar), snapshot, generation) ?: snapshot + } + if (!hadPubkyAppProfile) { + pubkyStore.update { it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners + publicKey) } + } + if (snapshot !== fetched) ownProfileRevision = snapshot.revision + val avatarPending = pendingAvatar != null && snapshot.profile.image == null && (migrating || retryingAvatar) + val profile = PubkyProfile.fromPubkyProfile(publicKey, snapshot.profile).let { + it.copy(tags = paykitData?.tags.orEmpty(), imageUrl = if (avatarPending) pendingAvatar else it.imageUrl) + } + if (!avatarPending && paykitData != null && paykitData != profile.toProfileData()) { + publishPaykitProfileCopy(publicKey, profile, generation, saveEpoch) + } + return profile to snapshot.revision + } + + private suspend fun migrateToPubkyApp( + publicKey: String, + data: PubkyProfileData, + generation: Long, + ): PubkyProfileSnapshot? = + runSuspendCatching { + val fitted = fitted(data.name, data.bio, data.links.map { PubkyProfileLink(it.label, it.url) }) + // An avatar already on pubky.app is kept as is; one on Paykit storage is moved below. + val keptImage = data.image?.takeIf { it.isPubkyAppFile() } + val update = PubkyProfileUpdate( + name = fitted.name, + bio = fitted.bio, + image = keptImage, + links = fitted.links.map { SdkPubkyProfileLink(title = it.label, url = it.url) }, + ) + val image = data.image?.takeIf { it.isPaykitStorage() } + // Recorded before the profile is created without it, so an app stopped before the move still retries it. + // A failed create keeps it too: the create may have landed, and the next load or save handles either case. + if (image != null) { + pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending + publicKey) } + } + val snapshot = profileWrite(generation) { + withPubkyAppScope { pubkyService.publishPubkyProfile(update, null, publicKey) } + } + Logger.info("Moved Bitkit profile to pubky.app", context = TAG) + image ?: return@runSuspendCatching snapshot + moveAvatar(publicKey, image, snapshot, generation) ?: snapshot + }.onFailure { Logger.warn("Failed to move Bitkit profile to pubky.app", it, context = TAG) }.getOrNull() + + private suspend fun moveAvatar( + publicKey: String, + uri: String, + snapshot: PubkyProfileSnapshot, + generation: Long, + ): PubkyProfileSnapshot? = runSuspendCatching { + val bytes = pubkyService.fetchFile(uri, PUBKY_APP_AVATAR_MAX_BYTES) + val contentType = when (uri.substringAfterLast('.').lowercase()) { + "png" -> "image/png" + "webp" -> "image/webp" + "gif" -> "image/gif" + else -> "image/jpeg" + } + profileWrite(generation) { + val image = withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(bytes, contentType, publicKey) } + withPubkyAppScope { + pubkyService.publishPubkyProfile( + PubkyProfileUpdate(null, null, image, null), + snapshot.revision, + publicKey + ) + } + } + }.onSuccess { + pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - publicKey) } + }.onFailure { + Logger.warn("Failed to move avatar to pubky.app", it, context = TAG) + pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending + publicKey) } + }.getOrNull() + + private fun Throwable.isConcurrentUpdate() = + generateSequence(this) { it.cause }.any { it is PaykitException.ConcurrentUpdate } + + private fun String.singleLine() = map { if (it.isISOControl()) ' ' else it }.joinToString("").trim() + + private fun String.toPubkyAppLinkUrl(): String? { + val value = trim().takeIf { it.isNotEmpty() } ?: return null + val isPhone = value.isPhoneNumber() + if (!isPhone && value.any { it.isWhitespace() }) return null + val url = when { + !isPhone && runCatching { URI(value).scheme }.getOrNull() != null -> value + value.isEmailAddress() -> "mailto:$value" + isPhone -> "tel:" + value.filterNot { it in " -()." } + '.' in value && !value.startsWith('.') -> "https://$value" + else -> return null + } + val parsed = runCatching { URI(url) }.getOrNull() + val valid = url.codePointCount(0, url.length) <= PUBKY_APP_LINK_URL_MAX_LENGTH && + parsed?.scheme != null && parsed.hasValidHost() + return url.takeIf { valid } + } + + /** + * Paykit's URL parser reads a web URL's host that ends in an all-digit label as an IPv4 address (one to four + * numbers, as in `127.1`) and rejects it when it is not one; other schemes keep such hosts as they are. + */ + private fun URI.hasValidHost(): Boolean { + if (scheme?.lowercase() !in WEB_SCHEMES) return true + // java.net.URI leaves the host null when the name is not a valid hostname, so fall back to the authority. + val host = host ?: rawAuthority?.substringAfterLast('@')?.substringBefore(':') ?: return true + if (!host.substringAfterLast('.').all { it.isDigit() }) return true + val numbers = host.split('.').map { it.toLongOrNull() ?: return false } + if (numbers.size !in 1..4) return false + val lastLimit = (1L shl (8 * (5 - numbers.size))) - 1 + return numbers.dropLast(1).all { it <= 255 } && numbers.last() <= lastLimit + } + + private fun String.isEmailAddress(): Boolean { + val parts = split('@') + return parts.size == 2 && parts[0].isNotEmpty() && '.' in parts[1] && '/' !in this + } + + private fun String.isPhoneNumber(): Boolean { + val digits = count { it.isDigit() } + return (startsWith('+') || digits >= 7) && digits >= 5 && all { it in "+0123456789 -()." } && '+' !in drop(1) && + !ISO_DATE.matches(this) && !IPV4.matches(this) + } + + private fun String.isPaykitStorage() = "/pub/paykit/" in this + + private fun String.isPubkyAppFile() = + "/pub/pubky.app/files/" in this && codePointCount(0, length) <= PUBKY_APP_LINK_URL_MAX_LENGTH + + private suspend fun publishPaykitProfileCopy( + publicKey: String, + profile: PubkyProfile, + generation: Long, + saveEpoch: Long, ) { - val data = PubkyProfile( - publicKey = "", - name = name, - bio = bio, - imageUrl = imageUrl, - links = links, - tags = tags, - status = null, - ).toProfileData() - pubkyService.publishPaykitProfile(data.toPaykitProfile()) + runSuspendCatching { + profileWrite(generation) { + // Checked under the write lock, so a save that starts after it writes its copy after this one. + if (profileSavesRunning.get() != 0 || profileSaveEpoch.get() != saveEpoch) return@profileWrite + pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile(), publicKey) + } + }.onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } + } + + /** Runs a write of a load or save that started at [generation], refused once a profile deletion began since. */ + private suspend fun profileWrite(generation: Long, block: suspend () -> T): T = profileWriteMutex.withLock { + check(profileDeletionGeneration.get() == generation) { "The profile was deleted since this write began" } + block() + } + + private suspend fun withPubkyAppScope(block: suspend () -> T): T = runSuspendCatching { block() }.getOrElse { + if (!it.isPaykitIdentityError() || !refreshSessionIfPossible().getOrDefault(false)) throw it + block() } private fun compressAvatar(imageBytes: ByteArray): ByteArray { @@ -1998,30 +2414,23 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { val prefixedKey = publicKey.ensurePubkyPrefix() if (!retry) return@withContext resolveProfileOnce(prefixedKey, lane) - var lastError: Throwable? = null - - repeat(2) { attempt -> - val result = runSuspendCatching { resolveProfileOnce(prefixedKey, lane) } - if (result.isSuccess && (result.getOrNull() != null || attempt == 1)) { - return@withContext result.getOrNull() - } - result.exceptionOrNull()?.let { error -> - lastError = error - } + readRetryingOnce(prefixedKey) { resolveProfileOnce(prefixedKey, lane) } + } + } - if (attempt == 0) { - Logger.warn( - "Retrying contact profile resolution for '${redacted(prefixedKey)}'", - lastError, - context = TAG, - ) - delay(250) - } + private suspend fun readRetryingOnce(publicKey: String, read: suspend () -> T?): T? { + var lastError: Throwable? = null + repeat(2) { attempt -> + val result = runSuspendCatching { read() } + if (result.isSuccess && (result.getOrNull() != null || attempt == 1)) return result.getOrNull() + result.exceptionOrNull()?.let { lastError = it } + if (attempt == 0) { + Logger.warn("Retrying profile read for '${redacted(publicKey)}'", lastError, context = TAG) + delay(250) } - - lastError?.let { throw it } - null } + lastError?.let { throw it } + return null } private suspend fun resolveProfileOnce(prefixedKey: String, lane: PaykitReadLane): PubkyProfile? = @@ -2145,6 +2554,7 @@ class PubkyRepo @Inject constructor( private fun setProfile(profile: PubkyProfile?) { profileWriteGeneration.incrementAndGet() + if (profile == null) ownProfileRevision = null _profile.update { profile } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 1301df5fbe..de8c204e7a 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -70,6 +70,8 @@ import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PubkyIdentityCapability import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile +import com.synonym.paykit.PubkyProfileSnapshot +import com.synonym.paykit.PubkyProfileUpdate import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult @@ -84,6 +86,7 @@ import com.synonym.paykit.defaultPubkyClientConfig import com.synonym.paykit.parsePubkyAuthUrl import com.synonym.paykit.paykitAuthorizerSessionCapabilities import com.synonym.paykit.paymentProofEventLen +import com.synonym.paykit.pubkyAppProfileSessionCapabilities import com.synonym.paykit.pubkyPublicKeyFromSecret import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic import dagger.hilt.android.qualifiers.ApplicationContext @@ -622,11 +625,12 @@ class PaykitSdkService @Inject constructor( publicRead(PaykitReadLane.Bulk, 5.seconds) { it.fetchPubkyFileBounded(uri, maxBytes) } ?: throw PubkyFileNotFoundError() - suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord { + suspend fun publishPaykitProfile(profile: PaykitProfile, expectedIdentity: String): PaykitProfileRecord { isSetup.await() return operationLock.withLock { withPaykitKey { handle -> val publicKey = requireNotNull(completeSdkCall { handle.identityStatus() }?.publicKey) + requireProfileIdentity(publicKey, expectedIdentity) val current = handle.fetchPaykitProfile(publicKey) completeSdkCall { handle.publishPaykitProfile(profile, current?.revision).also { notifyBackupStateChanged() } @@ -668,6 +672,46 @@ class PaykitSdkService @Inject constructor( suspend fun fetchPubkyProfile(publicKey: String): PubkyProfile? = publicRead { it.fetchPubkyProfile(publicKey) }?.profile + suspend fun fetchPubkyProfileWithRevision(publicKey: String): PubkyProfileSnapshot? = + publicRead { it.fetchPubkyProfileWithRevision(publicKey) } + + suspend fun fetchPaykitProfile(publicKey: String): PaykitProfileRecord? = + publicRead { it.fetchPaykitProfile(publicKey) } + + suspend fun publishPubkyProfile( + update: PubkyProfileUpdate, + expectedRevision: String?, + expectedIdentity: String, + ): PubkyProfileSnapshot { + isSetup.await() + return operationLock.withLock { + requireProfileIdentity(currentIdentity(), expectedIdentity) + completeSdkCall { handle().publishPubkyProfile(update, expectedRevision) } + } + } + + suspend fun deletePubkyProfile(expectedRevision: String, expectedIdentity: String) { + isSetup.await() + operationLock.withLock { + requireProfileIdentity(currentIdentity(), expectedIdentity) + completeSdkCall { handle().deletePubkyProfile(expectedRevision) } + } + } + + suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String, expectedIdentity: String): String { + isSetup.await() + return operationLock.withLock { + requireProfileIdentity(currentIdentity(), expectedIdentity) + completeSdkCall { handle().uploadPubkyAppAvatar(bytes, contentType) } + } + } + + private suspend fun currentIdentity(): String? = completeSdkCall { handle().identityStatus() }?.publicKey + + private fun requireProfileIdentity(current: String?, expectedIdentity: String) { + if (!PubkyPublicKeyFormat.matches(current, expectedIdentity)) throw PubkyProfileIdentityChangedError() + } + suspend fun fetchPubkyFollows(publicKey: String): List = publicRead { it.fetchPubkyFollows(publicKey, maxEntries = 10_000u) } @@ -885,7 +929,12 @@ class PaykitSdkService @Inject constructor( } } - fun requiredCapabilities(): String = paykitAuthorizerSessionCapabilities() + /** + * Scope for sessions Bitkit creates: Paykit plus the shared pubky.app profile and its avatar. + * Restoring a saved session still checks only the Paykit scope, so sessions from older builds keep working. + */ + fun requiredCapabilities(): String = + "${paykitAuthorizerSessionCapabilities()},${pubkyAppProfileSessionCapabilities()}" suspend fun syncPrivatePaymentListsWithReservations( updates: List, @@ -2277,3 +2326,6 @@ internal fun paykitStorageCallback(code: String, operation: () -> T): T = if (it is PaykitException) throw it throw PaykitException.Storage(code = code, context = "Platform Paykit storage operation failed") } + +/** The signed-in identity changed before a profile write, so it wrote nothing. */ +class PubkyProfileIdentityChangedError : AppError("Pubky identity changed before the profile write") diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 413fb3c90c..1b5264c76e 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -4,9 +4,12 @@ import com.synonym.bitkitcore.approvePubkyAuth import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PaykitProfileRecord import com.synonym.paykit.PaykitPublicKeys import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim +import com.synonym.paykit.PubkyProfileSnapshot +import com.synonym.paykit.PubkyProfileUpdate import com.synonym.paykit.PubkySessionBootstrapResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -199,8 +202,8 @@ class PubkyService @Inject constructor( // region Profile & contacts - suspend fun publishPaykitProfile(profile: PaykitProfile) = ServiceQueue.PUBKY.background { - paykitSdkService.publishPaykitProfile(profile) + suspend fun publishPaykitProfile(profile: PaykitProfile, expectedIdentity: String) = ServiceQueue.PUBKY.background { + paykitSdkService.publishPaykitProfile(profile, expectedIdentity) } suspend fun uploadProfileAvatar(bytes: ByteArray, contentType: String): String = ServiceQueue.PUBKY.background { @@ -211,6 +214,29 @@ class PubkyService @Inject constructor( paykitSdkService.deletePaykitProfile() } + suspend fun fetchPubkyProfileWithRevision(publicKey: String): PubkyProfileSnapshot? = cancellablePublicRead { + paykitSdkService.fetchPubkyProfileWithRevision(publicKey) + } + + suspend fun fetchPaykitProfile(publicKey: String): PaykitProfileRecord? = cancellablePublicRead { + paykitSdkService.fetchPaykitProfile(publicKey) + } + + suspend fun publishPubkyProfile( + update: PubkyProfileUpdate, + expectedRevision: String?, + expectedIdentity: String, + ): PubkyProfileSnapshot = ServiceQueue.PUBKY.background { + paykitSdkService.publishPubkyProfile(update, expectedRevision, expectedIdentity) + } + + suspend fun deletePubkyProfile(expectedRevision: String, expectedIdentity: String) = ServiceQueue.PUBKY.background { + paykitSdkService.deletePubkyProfile(expectedRevision, expectedIdentity) + } + + suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String, expectedIdentity: String): String = + ServiceQueue.PUBKY.background { paykitSdkService.uploadPubkyAppAvatar(bytes, contentType, expectedIdentity) } + suspend fun getContacts(publicKey: String): List = cancellablePublicRead { paykitSdkService.fetchPubkyFollows(publicKey) } diff --git a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt index ba463f8eb7..8d4f534d38 100644 --- a/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt +++ b/app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt @@ -51,6 +51,7 @@ import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.models.takeCodePoints import to.bitkit.ui.theme.AppTextFieldDefaults import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface @@ -81,6 +82,7 @@ fun ProfileEditForm( modifier: Modifier = Modifier, isSaving: Boolean = false, isDeleting: Boolean = false, + maxLinks: Int? = null, avatarContent: @Composable () -> Unit = {}, publicKeyLabel: String? = null, bioPlaceholder: String? = null, @@ -132,7 +134,7 @@ fun ProfileEditForm( TextInput( enabled = !isBusy, value = bio, - onValueChange = { onBioChange(it.take(BIO_MAX_LENGTH)) }, + onValueChange = { onBioChange(it.takeCodePoints(BIO_MAX_LENGTH)) }, placeholder = resolvedBioPlaceholder, minLines = 3, maxLines = 4, @@ -171,25 +173,27 @@ fun ProfileEditForm( ) } } - PrimaryButton( - text = stringResource(R.string.profile__add_link), - enabled = !isBusy, - onClick = { - focusManager.clearFocus(force = true) - keyboardController?.hide() - onAddLink() - }, - size = ButtonSize.Small, - fullWidth = false, - icon = { - Icon( - painter = painterResource(R.drawable.ic_link), - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - }, - modifier = Modifier.testTag("ProfileEditAddLink") - ) + if (maxLinks == null || links.size < maxLinks) { + PrimaryButton( + text = stringResource(R.string.profile__add_link), + enabled = !isBusy, + onClick = { + focusManager.clearFocus(force = true) + keyboardController?.hide() + onAddLink() + }, + size = ButtonSize.Small, + fullWidth = false, + icon = { + Icon( + painter = painterResource(R.drawable.ic_link), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + modifier = Modifier.testTag("ProfileEditAddLink") + ) + } HorizontalDivider() FieldLabel(text = stringResource(R.string.profile__edit_tags)) { diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt index df88a63246..95c1aebf0d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import to.bitkit.R +import to.bitkit.models.isValidProfileName import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator @@ -137,7 +138,7 @@ private fun Content( PrimaryButton( text = stringResource(R.string.common__continue), onClick = onSave, - enabled = uiState.name.isNotBlank() && !uiState.isSaving, + enabled = isValidProfileName(uiState.name) && !uiState.isSaving, isLoading = uiState.isSaving, modifier = Modifier.testTag("CreateProfileSave") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt index 441cd906cd..2daf5d751f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt @@ -17,8 +17,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.models.PROFILE_NAME_MAX_LENGTH import to.bitkit.models.PubkyProfileLink import to.bitkit.models.Toast +import to.bitkit.models.takeCodePoints +import to.bitkit.repositories.PubkyProfileChangedError import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus @@ -125,7 +128,7 @@ class CreateProfileViewModel @Inject constructor( } fun onNameChange(name: String) { - _uiState.update { it.copy(name = name) } + _uiState.update { it.copy(name = name.takeCodePoints(PROFILE_NAME_MAX_LENGTH)) } } fun onBioChange(bio: String) { @@ -198,11 +201,13 @@ class CreateProfileViewModel @Inject constructor( }.onFailure { Logger.error("Failed to create identity", it, context = TAG) _uiState.update { it.copy(isSaving = false) } + val changed = it is PubkyProfileChangedError ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.profile__create_error), - description = it.message, + description = if (changed) context.getString(R.string.profile__edit_changed_error) else it.message, ) + if (changed) deriveAndCheckRemote() } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt index 45563016c3..b824b89a96 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileScreen.kt @@ -30,6 +30,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.models.PROFILE_LINKS_MAX_COUNT +import to.bitkit.models.isValidProfileName import to.bitkit.ui.components.AddLinkSheet import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.GradientCircularProgressIndicator @@ -166,10 +168,11 @@ private fun Content( onAddTag = onAddTag, onSave = onSave, onCancel = onBackClick, - isSaveEnabled = uiState.name.isNotBlank() && !uiState.isBusy, + isSaveEnabled = isValidProfileName(uiState.name) && !uiState.isBusy, topBar = topBar, isSaving = uiState.isSaving, isDeleting = uiState.isDeleting, + maxLinks = PROFILE_LINKS_MAX_COUNT, avatarContent = { AvatarSection( imageUrl = uiState.imageUrl, diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index e2628806cf..ad2302f4a4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt @@ -22,9 +22,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import to.bitkit.R import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.PROFILE_NAME_MAX_LENGTH import to.bitkit.models.PubkyProfileLink import to.bitkit.models.Toast +import to.bitkit.models.takeCodePoints import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.ProfileEditBase +import to.bitkit.repositories.PubkyProfileChangedError +import to.bitkit.repositories.PubkyProfileTagsNotSavedError import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus @@ -41,6 +46,8 @@ class EditProfileViewModel @Inject constructor( private const val TAG = "EditProfileViewModel" } + private var editBase = ProfileEditBase(profile = null, revision = null) + private val _uiState = MutableStateFlow(EditProfileUiState()) val uiState = _uiState.asStateFlow() @@ -54,7 +61,8 @@ class EditProfileViewModel @Inject constructor( private fun loadProfile() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - val profile = pubkyRepo.profile.value + editBase = pubkyRepo.profileEditBase() + val profile = editBase.profile val publicKey = pubkyRepo.publicKey.value.orEmpty() val removal = if (pubkyRepo.isRingAdoptedIdentity()) ProfileRemoval.UNLINK else ProfileRemoval.DELETE if (profile != null) { @@ -78,6 +86,14 @@ class EditProfileViewModel @Inject constructor( } } + private fun reloadAfterExternalEdit() { + viewModelScope.launch { + pubkyRepo.loadProfile() + _uiState.update { it.copy(newAvatarUri = null, newAvatarBytes = null) } + loadProfile() + } + } + fun onAvatarSelected(uri: Uri) { viewModelScope.launch { runCatching { @@ -97,7 +113,7 @@ class EditProfileViewModel @Inject constructor( } fun onNameChange(name: String) { - _uiState.update { it.copy(name = name) } + _uiState.update { it.copy(name = name.takeCodePoints(PROFILE_NAME_MAX_LENGTH)) } } fun onBioChange(bio: String) { @@ -196,6 +212,7 @@ class EditProfileViewModel @Inject constructor( links = state.links.map { PubkyProfileLink(label = it.label, url = it.url) }, tags = state.tags, imageUrl = imageUrl, + editBase = editBase, ).onSuccess { _uiState.update { it.copy(isSaving = false) } ToastEventBus.send( @@ -207,11 +224,15 @@ class EditProfileViewModel @Inject constructor( }.onFailure { Logger.error("Failed to save profile", it, context = TAG) _uiState.update { it.copy(isSaving = false) } + val changed = it is PubkyProfileChangedError ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.profile__edit_save_error), - description = it.message, + description = it.profileSaveErrorText(context), ) + if (changed) reloadAfterExternalEdit() + // The other fields are on pubky.app, so a retry starts from their revision. + if (it is PubkyProfileTagsNotSavedError) editBase = it.base } } } @@ -355,3 +376,9 @@ sealed interface EditProfileEffect { data object DeleteSuccess : EditProfileEffect data object DisconnectSuccess : EditProfileEffect } + +internal fun Throwable.profileSaveErrorText(context: Context): String? = when (this) { + is PubkyProfileChangedError -> context.getString(R.string.profile__edit_changed_error) + is PubkyProfileTagsNotSavedError -> context.getString(R.string.profile__edit_tags_error) + else -> message +} 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 4880ed4375..862871d2b4 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 @@ -246,7 +246,7 @@ class ProfileViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.profile__edit_save_error), - description = it.message, + description = it.profileSaveErrorText(context), ) } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a35370765e..252474ac54 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1368,13 +1368,15 @@ فشل فصل الملف الشخصي النبذة ملاحظة قصيرة. أخبرنا قليلاً عن نفسك. + تغيّر ملفك الشخصي في pubky.app. حمّل Bitkit أحدث نسخة، لذا أعد إجراء تعديلك. حذف اسمك تعديل الملف الشخصي - يرجى ملاحظة أن معلومات الملف الشخصي مخزنة في ملفات عامة. + هذا هو ملفك الشخصي على pubky.app. إنه عام، والتغييرات التي تجريها هنا تظهر أيضًا على pubky.app. فشل حفظ الملف الشخصي تم حفظ الملف الشخصي العلامات + تعذّر على Bitkit حفظ وسومك. حُفظت تغييراتك الأخرى، لذا حاول حفظ الوسوم مرة أخرى. إلغاء الربط تعذر تحميل ملفك الشخصي. مع ملفك الشخصي المحمول، يمكن لجهات اتصالك الوصول إليك والدفع لك في أي مكان. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 406f736689..d889932032 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1394,12 +1394,14 @@ No se pudo desconectar el perfil BIO Nota breve. Cuente un poco sobre usted. + Tu perfil cambió en pubky.app. Bitkit cargó la versión más reciente, así que vuelve a hacer tu cambio. ELIMINAR SU NOMBRE - Tenga en cuenta que la información del perfil se guarda en archivos públicos. + Este es tu perfil de pubky.app. Es público, y los cambios que hagas aquí también se ven en pubky.app. No se pudo guardar el perfil Perfil guardado ETIQUETAS + Bitkit no pudo guardar tus etiquetas. Tus otros cambios se guardaron, así que vuelve a intentar con las etiquetas. DESVINCULAR No se pudo cargar su perfil. Con su perfil portátil, sus contactos pueden encontrarlo y pagarle en cualquier lugar. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 0f69599634..2631eaac90 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1390,12 +1390,14 @@ No s\'ha pogut desconnectar el perfil BIOGRAFIA Nota breu. Explica una mica sobre tu. + El teu perfil ha canviat a pubky.app. Bitkit ha carregat la versió més recent, així que torna a fer la teva edició. ELIMINAR EL TEU NOM - Tingues en compte que la informació del perfil es desa en fitxers públics. + Aquest és el teu perfil de pubky.app. És públic, i els canvis que hi facis aquí també es veuen a pubky.app. No s\'ha pogut desar el perfil Perfil desat ETIQUETES + Bitkit no ha pogut desar les teves etiquetes. Els altres canvis s\'han desat, així que torna a provar amb les etiquetes. DESVINCULAR No s\'ha pogut carregar el teu perfil. Amb el teu perfil portable, els teus contactes et poden trobar i pagar a qualsevol lloc. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index c3bc277e18..09415d0c15 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1392,12 +1392,14 @@ Profil se nepodařilo odpojit BIO Krátká poznámka. Řekněte něco o sobě. + Tvůj profil se v pubky.app změnil. Bitkit načetl nejnovější verzi, proveď proto úpravu znovu. SMAZAT VAŠE JMÉNO - Upozorňujeme, že informace o profilu jsou uloženy ve veřejných souborech. + Toto je tvůj profil na pubky.app. Je veřejný a změny, které zde provedeš, se zobrazí i na pubky.app. Profil se nepodařilo uložit Profil uložen TAGY + Bitkit nemohl uložit vaše štítky. Ostatní změny byly uloženy, tak zkuste štítky znovu. ZRUŠIT PROPOJENÍ Váš profil nelze načíst. Díky přenosnému profilu vás kontakty mohou kdekoli zastihnout a zaplatit vám. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c580d7bfdd..b5a03271d8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1390,12 +1390,14 @@ Profil konnte nicht getrennt werden BIO Kurze Notiz. Erzähl ein bisschen über dich. + Dein Profil wurde in pubky.app geändert. Bitkit hat die neueste Version geladen, nimm deine Änderung bitte erneut vor. LÖSCHEN DEIN NAME - Bitte beachte, dass Profilinformationen in öffentlichen Dateien gespeichert werden. + Das ist dein pubky.app-Profil. Es ist öffentlich, und Änderungen, die du hier vornimmst, erscheinen auch auf pubky.app. Profil konnte nicht gespeichert werden Profil gespeichert TAGS + Bitkit konnte deine Tags nicht speichern. Deine anderen Änderungen wurden gespeichert, versuche es mit den Tags erneut. VERKNÜPFUNG AUFHEBEN Dein Profil kann nicht geladen werden. Mit deinem portablen Profil können deine Kontakte dich überall erreichen und bezahlen. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 7b910ece0b..6e7db91ba3 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1374,13 +1374,15 @@ Αποτυχία αποσύνδεσης προφίλ ΒΙΟΓΡΑΦΙΚΟ Σύντομη σημείωση. Πες λίγα λόγια για τον εαυτό σου. + Το προφίλ σου άλλαξε στο pubky.app. Το Bitkit φόρτωσε την πιο πρόσφατη έκδοση, οπότε κάνε ξανά την αλλαγή σου. ΔΙΑΓΡΑΦΗ ΤΟ ΟΝΟΜΑ ΣΟΥ Επεξεργασία προφίλ - Τα στοιχεία του προφίλ αποθηκεύονται σε δημόσια αρχεία. + Αυτό είναι το προφίλ σου στο pubky.app. Είναι δημόσιο, και οι αλλαγές που κάνεις εδώ εμφανίζονται και στο pubky.app. Αποτυχία αποθήκευσης προφίλ Το προφίλ αποθηκεύτηκε ΕΤΙΚΕΤΕΣ + Το Bitkit δεν μπόρεσε να αποθηκεύσει τις ετικέτες σου. Οι άλλες αλλαγές σου αποθηκεύτηκαν, οπότε δοκίμασε ξανά τις ετικέτες. ΑΠΟΣΥΝΔΕΣΗ Δεν ήταν δυνατή η φόρτωση του προφίλ σου. Με το φορητό προφίλ σου, οι επαφές σου μπορούν να σε βρουν και να σε πληρώσουν οπουδήποτε. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index f9524b370e..798ccde375 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1388,12 +1388,14 @@ No se pudo desconectar el perfil BIO Nota breve. Cuenta algo sobre ti. + Tu perfil ha cambiado en pubky.app. Bitkit ha cargado la versión más reciente, así que vuelve a hacer tu cambio. ELIMINAR TU NOMBRE - Ten en cuenta que la información del perfil se guarda en archivos públicos. + Este es tu perfil de pubky.app. Es público, y los cambios que hagas aquí también se ven en pubky.app. No se pudo guardar el perfil Perfil guardado ETIQUETAS + Bitkit no ha podido guardar tus etiquetas. Tus otros cambios se han guardado, así que vuelve a intentarlo con las etiquetas. DESVINCULAR No se pudo cargar tu perfil. Con tu perfil portátil, tus contactos pueden localizarte y pagarte en cualquier lugar. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6153cbe7f7..da39a0733e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1387,12 +1387,14 @@ No se pudo desconectar el perfil BIO Nota breve. Cuente algo sobre usted. + Tu perfil cambió en pubky.app. Bitkit cargó la versión más reciente, así que vuelve a hacer tu cambio. ELIMINAR SU NOMBRE - Tenga en cuenta que la información del perfil se guarda en archivos públicos. + Este es tu perfil de pubky.app. Es público, y los cambios que hagas aquí también se ven en pubky.app. No se pudo guardar el perfil Perfil guardado ETIQUETAS + Bitkit no pudo guardar tus etiquetas. Tus otros cambios se guardaron, así que vuelve a intentar con las etiquetas. DESVINCULAR No se pudo cargar su perfil. Con su perfil portátil, sus contactos pueden localizarle y pagarle en cualquier lugar. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 770a8931d8..a0bb039db4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1388,12 +1388,14 @@ Échec de la déconnexion du profil BIO Courte note. Parlez un peu de vous. + Ton profil a changé sur pubky.app. Bitkit a chargé la version la plus récente, refais donc ta modification. SUPPRIMER VOTRE NOM - Veuillez noter que les informations de profil sont stockées dans des fichiers publics. + Voici ton profil pubky.app. Il est public, et les modifications que tu fais ici apparaissent aussi sur pubky.app. Échec de l\'enregistrement du profil Profil enregistré ÉTIQUETTES + Bitkit n\'a pas pu enregistrer vos étiquettes. Vos autres modifications ont été enregistrées, réessayez pour les étiquettes. DISSOCIER Impossible de charger votre profil. Grâce à votre profil portable, vos contacts peuvent vous joindre et vous payer partout. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e0f7b6783b..0cde7fec46 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1393,12 +1393,14 @@ Impossibile disconnettere il profilo BIO Breve nota. Racconta qualcosa di te. + Il tuo profilo è cambiato su pubky.app. Bitkit ha caricato la versione più recente, quindi ripeti la modifica. ELIMINA IL TUO NOME - Tieni presente che le informazioni del profilo sono salvate in file pubblici. + Questo è il tuo profilo pubky.app. È pubblico e le modifiche che fai qui compaiono anche su pubky.app. Impossibile salvare il profilo Profilo salvato TAG + Bitkit non è riuscito a salvare i tuoi tag. Le altre modifiche sono state salvate, quindi riprova con i tag. SCOLLEGA Impossibile caricare il tuo profilo. Con il tuo profilo portatile i tuoi contatti possono raggiungerti e pagarti ovunque. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 15a16a2f94..80fb19d5f9 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1390,12 +1390,14 @@ Profiel ontkoppelen mislukt BIO Korte notitie. Vertel iets over uzelf. + Je profiel is gewijzigd in pubky.app. Bitkit heeft de nieuwste versie geladen, dus voer je wijziging opnieuw uit. VERWIJDEREN UW NAAM - Let op: profielgegevens worden opgeslagen in openbare bestanden. + Dit is je pubky.app-profiel. Het is openbaar, en wijzigingen die je hier maakt, verschijnen ook op pubky.app. Profiel opslaan mislukt Profiel opgeslagen TAGS + Bitkit kon je tags niet opslaan. Je andere wijzigingen zijn opgeslagen, dus probeer de tags opnieuw. ONTKOPPELEN Kan uw profiel niet laden. Met uw draagbare profiel kunnen uw contacten u overal bereiken en betalen. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a663507d88..2ee8398d6b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1391,12 +1391,14 @@ Nie udało się odłączyć profilu BIO Krótka notatka. Napisz coś o sobie. + Twój profil zmienił się w pubky.app. Bitkit wczytał najnowszą wersję, więc wprowadź zmianę ponownie. USUWANIE TWOJE IMIĘ - Pamiętaj, że dane profilu są przechowywane w publicznych plikach. + To jest Twój profil pubky.app. Jest publiczny, a zmiany wprowadzone tutaj pojawią się też w pubky.app. Nie udało się zapisać profilu Profil zapisany TAGI + Bitkit nie mógł zapisać Twoich tagów. Pozostałe zmiany zostały zapisane, więc spróbuj ponownie z tagami. ODŁĄCZANIE Nie można wczytać Twojego profilu. Dzięki przenośnemu profilowi Twoje kontakty mogą skontaktować się z Tobą i zapłacić Ci w dowolnym miejscu. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 588f5e8786..68f0de7921 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1393,12 +1393,14 @@ Falha ao desconectar perfil BIO Nota curta. Conte um pouco sobre você. + Seu perfil mudou no pubky.app. O Bitkit carregou a versão mais recente, então faça sua alteração de novo. EXCLUIR SEU NOME - Observe que as informações do perfil são armazenadas em arquivos públicos. + Este é o seu perfil do pubky.app. Ele é público, e as alterações que você faz aqui também aparecem no pubky.app. Falha ao salvar perfil Perfil salvo TAGS + O Bitkit não conseguiu salvar suas tags. Suas outras alterações foram salvas, então tente as tags novamente. DESVINCULAR Não foi possível carregar seu perfil. Com seu perfil portátil, seus contatos podem encontrar você e pagar você em qualquer lugar. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index de942b10ae..9656a12682 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1367,13 +1367,15 @@ Falha ao desligar o perfil BIOGRAFIA Nota breve. Fale um pouco sobre si. + O teu perfil mudou no pubky.app. A Bitkit carregou a versão mais recente, por isso faz novamente a tua alteração. ELIMINAR O SEU NOME Editar perfil - Tenha em atenção que as informações do perfil são guardadas em ficheiros públicos. + Este é o teu perfil do pubky.app. É público, e as alterações que fazes aqui também aparecem no pubky.app. Falha ao guardar o perfil Perfil guardado ETIQUETAS + O Bitkit não conseguiu guardar as suas etiquetas. As outras alterações foram guardadas, por isso tente as etiquetas novamente. DESASSOCIAR Não foi possível carregar o seu perfil. Com o seu perfil portátil, os seus contactos podem encontrá-lo e pagar-lhe em qualquer lugar. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8d095d21c9..921be4a447 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1408,12 +1408,14 @@ Не удалось отключить профиль О СЕБЕ Короткая заметка. Расскажите немного о себе. + Ваш профиль изменился в pubky.app. Bitkit загрузил последнюю версию, поэтому внесите изменение снова. УДАЛЕНИЕ ВАШЕ ИМЯ - Обратите внимание: данные профиля хранятся в публичных файлах. + Это ваш профиль pubky.app. Он публичный, и изменения, которые вы вносите здесь, также видны в pubky.app. Не удалось сохранить профиль Профиль сохранён ТЕГИ + Bitkit не удалось сохранить ваши теги. Остальные изменения сохранены, поэтому попробуйте сохранить теги ещё раз. ОТВЯЗКА Не удалось загрузить ваш профиль. С переносимым профилем ваши контакты смогут связаться с вами и заплатить вам где угодно. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a36800ae87..b29eb9cdbc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -703,13 +703,15 @@ Failed to disconnect profile BIO Short note. Tell a bit about yourself. + Your profile changed in pubky.app. Bitkit loaded the latest version, so make your edit again. DELETE YOUR NAME Edit Profile - Please note profile information is stored in public files. + This is your pubky.app profile. It is public, and changes you make here also show on pubky.app. Failed to save profile Profile saved TAGS + Bitkit could not save your tags. Your other changes were saved, so try the tags again. UNLINK Unable to load your profile. With your portable profile your contacts can reach you and pay you anywhere. diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 77a21e6889..e4b8b77df3 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -8,12 +8,15 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate import com.synonym.paykit.PaykitException import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PaykitProfileRecord import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PaykitStateObservation import com.synonym.paykit.ProfileResolution import com.synonym.paykit.ProfileSource import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyAuthDetails +import com.synonym.paykit.PubkyProfileSnapshot +import com.synonym.paykit.PubkyProfileUpdate import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicationStatus import io.ktor.client.HttpClient @@ -53,6 +56,7 @@ import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn @@ -60,6 +64,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.nullableArgumentCaptor import org.mockito.kotlin.reset import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -81,6 +86,8 @@ import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthClaim.Item import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile +import to.bitkit.models.PubkyProfileData +import to.bitkit.models.PubkyProfileDataLink import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 import to.bitkit.services.PaykitReadLane @@ -92,6 +99,7 @@ import to.bitkit.test.forEachCase import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame @@ -102,6 +110,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlin.time.Instant import com.synonym.paykit.PubkyProfile as SdkPubkyProfile +import com.synonym.paykit.PubkyProfileLink as SdkPubkyProfileLink @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) class PubkyRepoTest : BaseUnitTest() { @@ -156,6 +165,20 @@ class PubkyRepoTest : BaseUnitTest() { Unit } whenever(pubkyService.contactRecords()).thenReturn(emptyList()) + whenever(pubkyService.currentPublicKey()).thenReturn(VALID_SELF_KEY) + whenever { pubkyService.publishPubkyProfile(any(), anyOrNull(), any()) }.doSuspendableAnswer { + val update = it.getArgument(0) + createSnapshot( + VALID_SELF_KEY, + SdkPubkyProfile( + name = update.name.orEmpty(), + bio = update.bio, + image = update.image?.ifEmpty { null }, + links = update.links.orEmpty(), + status = null, + ), + ) + } whenever { pubkyService.observedContactRecords(any()) }.doSuspendableAnswer { pubkyService.contactRecords() } whenever { pubkyService.initializeAndImportSession(any()) }.doSuspendableAnswer { runSuspendCatching { pubkyService.importSession(it.getArgument(0)) } @@ -558,7 +581,9 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.deriveSecretKey("test mnemonic")).thenReturn("test-secret") whenever(pubkyService.publicKeyFromSecret("test-secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) whenever(pubkyService.signUp("test-secret", "test-homeserver", "test-code")).thenReturn(Unit) - whenever(pubkyService.publishPaykitProfile(any())).thenAnswer { throw TestAppError("Publish failed") } + whenever( + pubkyService.publishPubkyProfile(any(), anyOrNull(), any()) + ).thenAnswer { throw TestAppError("Publish failed") } whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } val result = sut.createIdentity( @@ -587,7 +612,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertFalse(profileSetupPending.value) verify(keychain).loadString(Keychain.Key.BIP39_MNEMONIC.name) - verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { publishPubkyProfile(any(), anyOrNull(), any()) } } @Test @@ -597,7 +622,7 @@ class PubkyRepoTest : BaseUnitTest() { profileSetupPending.value = true whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret") whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) - whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + whenever(pubkyService.publishPaykitProfile(any(), any())).thenReturn(mock()) val result = sut.createIdentity("Restored", "", emptyList(), emptyList(), null) @@ -624,7 +649,7 @@ class PubkyRepoTest : BaseUnitTest() { verify(keychain, never()).loadString(Keychain.Key.BIP39_MNEMONIC.name) verifyBlocking(pubkyService, never()) { signIn(any()) } verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } - verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { publishPubkyProfile(any(), anyOrNull(), any()) } } @Test @@ -632,13 +657,13 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret") whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY) whenever(pubkyService.signIn("local-secret")).thenAnswer { throw TestAppError("offline") }.thenReturn(Unit) - whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + whenever(pubkyService.publishPaykitProfile(any(), any())).thenReturn(mock()) val firstResult = sut.createIdentity("Test", "", emptyList(), emptyList(), null) assertEquals("offline", firstResult.exceptionOrNull()?.message) assertNull(sut.publicKey.value) - verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { publishPubkyProfile(any(), anyOrNull(), any()) } assertTrue(sut.createIdentity("Test", "", emptyList(), emptyList(), null).isSuccess) assertEquals(VALID_SELF_KEY, sut.publicKey.value) @@ -655,9 +680,9 @@ class PubkyRepoTest : BaseUnitTest() { val existingProfile = sut.profile.value whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret") whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY) - whenever(pubkyService.publishPaykitProfile(any())) + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())) .thenAnswer { throw TestAppError("offline") } - .thenReturn(mock()) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Updated"))) val firstResult = sut.createIdentity("Updated", "", emptyList(), emptyList(), null) @@ -687,12 +712,12 @@ class PubkyRepoTest : BaseUnitTest() { } Unit } - whenever(pubkyService.publishPaykitProfile(any())).doSuspendableAnswer { + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())).doSuspendableAnswer { if (cancelDuringSignIn == false) { operationStarted.complete(Unit) awaitCancellation() } - mock() + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } for (duringSignIn in listOf(true, false)) { cancelDuringSignIn = duringSignIn @@ -721,7 +746,7 @@ class PubkyRepoTest : BaseUnitTest() { stubSignupKeys() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("ring-session") whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("") - whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + whenever(pubkyService.publishPaykitProfile(any(), any())).thenReturn(mock()) val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) @@ -741,7 +766,7 @@ class PubkyRepoTest : BaseUnitTest() { throw AppError(PaykitException.Policy("policy_error", "Identity lives on another homeserver")) } whenever(pubkyService.signIn("secret")).thenReturn(Unit) - whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + whenever(pubkyService.publishPaykitProfile(any(), any())).thenReturn(mock()) val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) httpClient.close() @@ -766,7 +791,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.exceptionOrNull()?.isPaykitTemporarilyUnavailable() == true) assertNull(sut.publicKey.value) verifyBlocking(pubkyService, times(1)) { signUp(any(), any(), any()) } - verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any(), any()) } } @Test @@ -776,7 +801,9 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) assertTrue(sut.approveSignupAuth(ringSignupRequest()).isSuccess) clearInvocations(pubkyService) - whenever(pubkyService.publishPaykitProfile(any())).thenAnswer { throw TestAppError("Publish failed") } + whenever( + pubkyService.publishPubkyProfile(any(), anyOrNull(), any()) + ).thenAnswer { throw TestAppError("Publish failed") } val result = sut.createIdentity( name = "Test", @@ -787,7 +814,7 @@ class PubkyRepoTest : BaseUnitTest() { ) assertTrue(result.isFailure) - verifyBlocking(pubkyService) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService) { publishPubkyProfile(any(), anyOrNull(), any()) } verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } verifyBlocking(pubkyService, never()) { signIn(any()) } verifyBlocking(pubkyService, never()) { signOut() } @@ -804,9 +831,9 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.deriveSecretKey("test mnemonic")).thenReturn("test-secret") whenever(pubkyService.publicKeyFromSecret("test-secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) whenever(pubkyService.signUp("test-secret", "test-homeserver", "test-code")).thenReturn(Unit) - whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) + whenever(pubkyService.publishPaykitProfile(any(), any())).thenReturn(mock()) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile())) whenever(pubkyService.contactRecords()).doSuspendableAnswer { contactsLoadStarted.complete(Unit) finishContactsLoad.await() @@ -841,7 +868,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `createIdentity returns cache failure after profile publication`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) profileSetupPending.value = true - whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + whenever(pubkyService.publishPaykitProfile(any(), any())).thenReturn(mock()) whenever { pubkyStore.update(any()) }.thenAnswer { throw TestAppError("cache failed") } @@ -855,8 +882,8 @@ class PubkyRepoTest : BaseUnitTest() { fun `createIdentity keeps the published profile without waiting for readback`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) profileSetupPending.value = true - whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + whenever(pubkyService.publishPaykitProfile(any(), any())).thenReturn(mock()) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) .thenReturn(null) clearInvocations(pubkyService) @@ -864,7 +891,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals("Test", sut.profile.value?.name) - verify(pubkyService, never()).resolveContactProfile(any(), any(), any(), anyOrNull()) + verify(pubkyService, never()).fetchPubkyProfileWithRevision(any()) } @Test @@ -881,9 +908,9 @@ class PubkyRepoTest : BaseUnitTest() { } emptyList() } - whenever(pubkyService.publishPaykitProfile(any())).doSuspendableAnswer { + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())).doSuspendableAnswer { profilePublished.complete(Unit) - mock() + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } val activeContactLoad = async { sut.loadContacts() } contactLoadStarted.await() @@ -918,8 +945,8 @@ class PubkyRepoTest : BaseUnitTest() { image = "pubky://image_uri", status = "active", ) - whenever(pubkyService.resolveContactProfile(pk, true)) - .thenReturn(createResolution(pk, pubkyProfile = pubkyProfile)) + whenever(pubkyService.fetchPubkyProfileWithRevision(pk)) + .thenReturn(createSnapshot(pk, pubkyProfile)) sut.loadProfile() @@ -931,6 +958,518 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals("active", profile.status) } + @Test + fun `saveProfile writes only the changed fields at the loaded revision`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY, profileName = "Old") + + assertTrue(saveNewProfile().isSuccess) + + verifyBlocking(pubkyService) { + publishPubkyProfile( + PubkyProfileUpdate(name = "New", bio = null, image = null, links = null), + "revision-Old", + VALID_SELF_KEY, + ) + } + assertEquals("New", sut.profile.value?.name) + } + + @Test + fun `saveProfile signs in again once when the session lacks the pubky app scope`() = test { + val secretKey = "local_secret" + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) + whenever(pubkyService.signIn(secretKey)).thenReturn(Unit) + whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())) + .thenAnswer { throw PaykitException.Identity("identity_error", "Missing capabilities") } + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "New"))) + + assertTrue(saveNewProfile().isSuccess) + + verifyBlocking(pubkyService) { signIn(secretKey) } + verifyBlocking(pubkyService, times(2)) { publishPubkyProfile(any(), anyOrNull(), any()) } + } + + @Test + fun `an edit opened before a refresh saves against the revision it opened with`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY, profileName = "Old") + val editBase = sut.profileEditBase() + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Newer"))) + sut.loadProfile() + assertEquals("revision-Newer", sut.profileEditBase().revision) + + sut.saveProfile( + name = "New", + bio = "", + links = emptyList(), + tags = emptyList(), + imageUrl = null, + editBase = editBase + ) + + verifyBlocking(pubkyService) { publishPubkyProfile(any(), eq("revision-Old"), any()) } + } + + @Test + fun `loadProfile keeps the Bitkit avatar when moving it to pubky app fails`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn( + PaykitProfileRecord( + publicKey = VALID_SELF_KEY, + profile = createPaykitProfile("Bitkit Name", image = "pubky://self/pub/paykit/blobs/avatar.jpg"), + path = "/pub/paykit/profile.json", + revision = "paykit-revision", + updatedAt = "2026-01-01T00:00:00Z", + ) + ) + whenever(pubkyService.fetchFile(any(), any())).thenAnswer { throw TestAppError("offline") } + clearInvocations(pubkyService) + + sut.loadProfile() + + assertEquals("pubky://self/pub/paykit/blobs/avatar.jpg", sut.profile.value?.imageUrl) + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any(), any()) } + } + + @Test + fun `createIdentity does not overwrite a pubky app profile its form was not filled from`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())) + .thenAnswer { throw PaykitException.ConcurrentUpdate("concurrent_update", "Pubky profile changed") } + + val result = sut.createIdentity("Updated", "", emptyList(), emptyList(), null) + + assertIs(result.exceptionOrNull()) + verifyBlocking(pubkyService, times(1)) { publishPubkyProfile(any(), anyOrNull(), any()) } + } + + @Test + fun `createIdentity does not restore against a profile from an earlier lookup after a failed one`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(bio = "Existing bio"))) + checkNotNull(sut.fetchRemoteProfile(VALID_SELF_KEY).getOrThrow()) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenAnswer { throw TestAppError("offline") } + assertTrue(sut.fetchRemoteProfile(VALID_SELF_KEY).isFailure) + clearInvocations(pubkyService) + + sut.createIdentity("New Name", "", emptyList(), emptyList(), null) + + val revision = nullableArgumentCaptor() + verifyBlocking(pubkyService) { publishPubkyProfile(any(), revision.capture(), any()) } + assertEquals(null, revision.firstValue) + } + + @Test + fun `createIdentity restores against the pubky app profile its form was filled from`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val restored = checkNotNull(sut.fetchRemoteProfile(VALID_SELF_KEY).getOrThrow()) + clearInvocations(pubkyService) + + sut.createIdentity("Renamed", restored.bio, restored.links, restored.tags, null) + + verifyBlocking(pubkyService) { + publishPubkyProfile(PubkyProfileUpdate("Renamed", null, null, null), "revision-Test", VALID_SELF_KEY) + } + } + + @Test + fun `saveProfile reports a tag change it could not store while the tags cannot be read`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenAnswer { throw TestAppError("offline") } + sut.loadProfile() + + val result = sut.saveProfile( + name = "Test", + bio = "", + links = emptyList(), + tags = listOf("new"), + imageUrl = null + ) + + val error = assertIs(result.exceptionOrNull()) + assertEquals("revision-", error.base.revision) + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any(), any()) } + } + + @Test + fun `loadProfile keeps an avatar set on pubky app over a pending avatar move`() = test { + whenever(pubkyStore.data).thenReturn( + flowOf( + PubkyStoreData( + pubkyAppProfileOwners = setOf(VALID_SELF_KEY), + pubkyAppAvatarPending = setOf(VALID_SELF_KEY) + ) + ) + ) + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn( + createSnapshot(VALID_SELF_KEY, createPubkyProfile(image = "pubky://self/pub/pubky.app/files/0000000000000")) + ) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn( + PaykitProfileRecord( + publicKey = VALID_SELF_KEY, + profile = createPaykitProfile("Test", image = "pubky://self/pub/paykit/blobs/avatar.jpg"), + path = "/pub/paykit/profile.json", + revision = "paykit-revision", + updatedAt = "2026-01-01T00:00:00Z", + ) + ) + + sut.loadProfile() + + assertEquals("pubky://self/pub/pubky.app/files/0000000000000", sut.profile.value?.imageUrl) + verifyBlocking(pubkyService, never()) { fetchFile(any(), any()) } + } + + @Test + fun `loadProfile records the avatar move before it creates the pubky app profile`() = test { + val store = MutableStateFlow(PubkyStoreData()) + whenever(pubkyStore.data).thenReturn(store) + whenever { pubkyStore.update(any()) }.thenAnswer { + store.value = it.getArgument<(PubkyStoreData) -> PubkyStoreData>(0)(store.value) + Unit + } + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn( + paykitRecord(createPaykitProfile("Bitkit Name", image = "pubky://self/pub/paykit/blobs/avatar.jpg")) + ) + var pendingAtCreate: Set? = null + whenever { pubkyService.publishPubkyProfile(any(), anyOrNull(), any()) }.doSuspendableAnswer { + pendingAtCreate = pendingAtCreate ?: store.value.pubkyAppAvatarPending + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Bitkit Name")) + } + store.value = PubkyStoreData() + + sut.loadProfile() + + assertEquals(setOf(VALID_SELF_KEY), pendingAtCreate) + } + + @Test + fun `loadProfile keeps a pending avatar move when the Paykit copy cannot be read`() = test { + val store = MutableStateFlow( + PubkyStoreData(pubkyAppProfileOwners = setOf(VALID_SELF_KEY), pubkyAppAvatarPending = setOf(VALID_SELF_KEY)) + ) + whenever(pubkyStore.data).thenReturn(store) + whenever { pubkyStore.update(any()) }.thenAnswer { + store.value = it.getArgument<(PubkyStoreData) -> PubkyStoreData>(0)(store.value) + Unit + } + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenAnswer { throw TestAppError("offline") } + authenticateForTesting(publicKey = VALID_SELF_KEY) + sut.loadProfile() + doReturn( + paykitRecord(createPaykitProfile("Test", image = "pubky://self/pub/paykit/blobs/avatar.jpg")) + ).whenever(pubkyService).fetchPaykitProfile(VALID_SELF_KEY) + clearInvocations(pubkyService) + + sut.loadProfile() + + verifyBlocking(pubkyService) { fetchFile(eq("pubky://self/pub/paykit/blobs/avatar.jpg"), any()) } + } + + @Test + fun `createIdentity fits a restored Bitkit-only profile to pubky app limits`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn( + paykitRecord( + PubkyProfileData( + name = "Bitkit Name", + bio = "b".repeat(200), + links = listOf(PubkyProfileDataLink("City", "New York")) + + (1..6).map { PubkyProfileDataLink("Site $it", "site$it.com") }, + ).toPaykitProfile() + ) + ) + val restored = checkNotNull(sut.fetchRemoteProfile(VALID_SELF_KEY).getOrThrow()) + clearInvocations(pubkyService) + + sut.createIdentity(restored.name, restored.bio, restored.links, restored.tags, null) + + val update = argumentCaptor() + verifyBlocking(pubkyService) { publishPubkyProfile(update.capture(), anyOrNull(), any()) } + assertEquals("b".repeat(160), update.firstValue.bio) + assertEquals((1..5).map { "https://site$it.com" }, update.firstValue.links?.map { it.url }) + } + + @Test + fun `saveProfile keeps the stored tags when a tag retry follows a failed read and write`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenAnswer { throw TestAppError("offline") } + sut.loadProfile() + doReturn( + paykitRecord(PubkyProfileData(name = "Test", bio = "", tags = listOf("friend", "work")).toPaykitProfile()) + ) + .whenever(pubkyService).fetchPaykitProfile(VALID_SELF_KEY) + whenever(pubkyService.publishPaykitProfile(any(), any())) + .thenAnswer { throw TestAppError("offline") } + .thenAnswer { paykitRecord(it.getArgument(0)) } + + val first = sut.saveProfile(name = "Test", bio = "", links = emptyList(), tags = listOf("vip"), imageUrl = null) + val base = assertIs(first.exceptionOrNull()).base + sut.saveProfile( + name = "Test", + bio = "", + links = emptyList(), + tags = listOf("vip"), + imageUrl = null, + editBase = base + ) + + val copy = argumentCaptor() + verifyBlocking(pubkyService, times(2)) { publishPaykitProfile(copy.capture(), any()) } + assertEquals(listOf("friend", "work", "vip"), PubkyProfileData.fromPaykitProfile(copy.lastValue).tags) + } + + @Test + fun `saveProfile keeps the stored tags when the edit began while they could not be read`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenAnswer { throw TestAppError("offline") } + sut.loadProfile() + val editBase = sut.profileEditBase() + doReturn( + paykitRecord(PubkyProfileData(name = "Test", bio = "", tags = listOf("friend", "work")).toPaykitProfile()) + ).whenever(pubkyService).fetchPaykitProfile(VALID_SELF_KEY) + whenever(pubkyService.publishPaykitProfile(any(), any())).thenAnswer { paykitRecord(it.getArgument(0)) } + sut.loadProfile() + + sut.saveProfile( + name = "Renamed", + bio = "", + links = emptyList(), + tags = emptyList(), + imageUrl = null, + editBase = editBase + ).getOrThrow() + + val copy = argumentCaptor() + verifyBlocking(pubkyService, atLeastOnce()) { publishPaykitProfile(copy.capture(), any()) } + assertEquals(listOf("friend", "work"), PubkyProfileData.fromPaykitProfile(copy.lastValue).tags) + } + + @Test + fun `deleteProfile deletes the pubky app profile of a wallet identity`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + + sut.deleteProfile() + + verifyBlocking(pubkyService) { deletePubkyProfile("revision-Test", VALID_SELF_KEY) } + } + + @Test + fun `deleteProfile is not followed by a profile copy write from a load under way`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)) + .thenReturn(paykitRecord(PubkyProfileData(name = "Old", bio = "").toPaykitProfile())) + whenever(pubkyService.publishPaykitProfile(any(), any())).thenAnswer { paykitRecord(it.getArgument(0)) } + val snapshot = createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Test")) + val loadReading = CompletableDeferred() + val finishLoad = CompletableDeferred() + var reads = 0 + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { + if (reads++ == 0) { + loadReading.complete(Unit) + finishLoad.await() + } + snapshot + } + val copyDeleted = CompletableDeferred() + whenever(pubkyService.deletePaykitProfile()).doSuspendableAnswer { + copyDeleted.complete(Unit) + Unit + } + clearInvocations(pubkyService) + + val load = async { sut.loadProfile() } + loadReading.await() + val deletion = async { sut.deleteProfile() } + copyDeleted.await() + finishLoad.complete(Unit) + deletion.await().getOrThrow() + load.await() + + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any(), any()) } + } + + @Test + fun `a load under way does not write back the tags a save replaced`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + var copy = PubkyProfileData(name = "Test", bio = "", tags = listOf("old")).toPaykitProfile() + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenAnswer { paykitRecord(copy) } + whenever(pubkyService.publishPaykitProfile(any(), any())).thenAnswer { + copy = it.getArgument(0) + paykitRecord(copy) + } + sut.loadProfile() + val loadReading = CompletableDeferred() + val finishLoad = CompletableDeferred() + val saved = createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "New")) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { + loadReading.complete(Unit) + finishLoad.await() + saved + } + + val load = async { sut.loadProfile() } + loadReading.await() + sut.saveProfile(name = "New", bio = "", links = emptyList(), tags = listOf("new"), imageUrl = null).getOrThrow() + finishLoad.complete(Unit) + load.await() + + assertEquals(listOf("new"), PubkyProfileData.fromPaykitProfile(copy).tags) + } + + @Test + fun `a load during two saves does not write the profile copy back`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val copy = PubkyProfileData(name = "Test", bio = "", tags = listOf("old")).toPaykitProfile() + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenAnswer { paykitRecord(copy) } + whenever(pubkyService.publishPaykitProfile(any(), any())).thenAnswer { paykitRecord(it.getArgument(0)) } + sut.loadProfile() + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Loaded"))) + whenever { pubkyService.publishPubkyProfile(any(), anyOrNull(), any()) } + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "New"))) + // Both saves wait between their pubky.app write and their copy write, outside the write lock. + val savesWaiting = CompletableDeferred() + val finishSaves = CompletableDeferred() + var waiting = 0 + whenever { pubkyStore.update(any()) }.doSuspendableAnswer { + if (++waiting <= 2) { + if (waiting == 2) savesWaiting.complete(Unit) + finishSaves.await() + } + Unit + } + clearInvocations(pubkyService) + + val saves = (1..2).map { + async { + sut.saveProfile(name = "New", bio = "", links = emptyList(), tags = listOf("new"), imageUrl = null) + } + } + savesWaiting.await() + sut.loadProfile() + + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any(), any()) } + finishSaves.complete(Unit) + saves.awaitAll() + } + + @Test + fun `loadProfile moves a multi-line bio and scheme-less links to pubky app`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn( + PaykitProfileRecord( + publicKey = VALID_SELF_KEY, + profile = PubkyProfileData( + name = "Bitkit Name", + bio = "line one\nline two", + links = listOf( + PubkyProfileDataLink("Site", "example.com"), + PubkyProfileDataLink("Mail", "a@b.com"), + PubkyProfileDataLink("Medium", "medium.com/@alice"), + PubkyProfileDataLink("City", "New York"), + PubkyProfileDataLink("Date", "2024-01-01"), + PubkyProfileDataLink("Dotted", "555.123.4567"), + PubkyProfileDataLink("Version", "v1.2.3"), + PubkyProfileDataLink("Loopback", "https://127.1"), + ), + ).toPaykitProfile(), + path = "/pub/paykit/profile.json", + revision = "paykit-revision", + updatedAt = "2026-01-01T00:00:00Z", + ) + ) + + sut.loadProfile() + + verifyBlocking(pubkyService) { + publishPubkyProfile( + PubkyProfileUpdate( + name = "Bitkit Name", + bio = "line one line two", + image = null, + links = listOf( + SdkPubkyProfileLink(title = "Site", url = "https://example.com"), + SdkPubkyProfileLink(title = "Mail", url = "mailto:a@b.com"), + SdkPubkyProfileLink(title = "Medium", url = "https://medium.com/@alice"), + SdkPubkyProfileLink(title = "Dotted", url = "tel:5551234567"), + SdkPubkyProfileLink(title = "Loopback", url = "https://127.1"), + ), + ), + null, + VALID_SELF_KEY, + ) + } + } + + @Test + fun `loadProfile shows the Bitkit profile when moving it to pubky app fails`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn( + PaykitProfileRecord( + publicKey = VALID_SELF_KEY, + profile = createPaykitProfile("Al"), + path = "/pub/paykit/profile.json", + revision = "paykit-revision", + updatedAt = "2026-01-01T00:00:00Z", + ) + ) + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())) + .thenAnswer { throw PaykitException.Protocol("protocol", "Pubky profile name must be 3 to 50 characters") } + + sut.loadProfile() + + assertEquals("Al", sut.profile.value?.name) + } + + @Test + fun `saveProfile reports a profile that changed in pubky app`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())) + .thenAnswer { throw PaykitException.ConcurrentUpdate("concurrent_update", "Pubky profile changed") } + + val result = saveNewProfile() + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `loadProfile moves a Bitkit-only profile to pubky app`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) + whenever(pubkyService.fetchPaykitProfile(VALID_SELF_KEY)).thenReturn( + PaykitProfileRecord( + publicKey = VALID_SELF_KEY, + profile = createPaykitProfile("Bitkit Name", bio = "b".repeat(200)), + path = "/pub/paykit/profile.json", + revision = "paykit-revision", + updatedAt = "2026-01-01T00:00:00Z", + ) + ) + + sut.loadProfile() + + verifyBlocking(pubkyService) { + publishPubkyProfile( + PubkyProfileUpdate(name = "Bitkit Name", bio = "b".repeat(160), image = null, links = emptyList()), + null, + VALID_SELF_KEY, + ) + } + assertEquals("Bitkit Name", sut.profile.value?.name) + } + @Test fun `loadProfile should keep existing profile on failure`() = test { authenticateForTesting() @@ -938,7 +1477,7 @@ class PubkyRepoTest : BaseUnitTest() { assertNotNull(existingProfile) val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" } - whenever(pubkyService.resolveContactProfile(pk, true)).thenAnswer { throw TestAppError("Network error") } + whenever(pubkyService.fetchPubkyProfileWithRevision(pk)).thenAnswer { throw TestAppError("Network error") } sut.loadProfile() @@ -949,13 +1488,13 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `loadProfile retries a failed own profile resolution once`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) .thenAnswer { throw TestAppError("Network error") } clearInvocations(pubkyService) sut.loadProfile() - verify(pubkyService, times(2)).resolveContactProfile(VALID_SELF_KEY, true) + verify(pubkyService, times(2)).fetchPubkyProfileWithRevision(VALID_SELF_KEY) } @Test @@ -964,10 +1503,10 @@ class PubkyRepoTest : BaseUnitTest() { resetForCase() authenticateForTesting(publicKey = VALID_SELF_KEY) val finishLoad = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { finishLoad.await() if (fails) throw TestAppError("Offline") - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Loaded")) + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Loaded")) } clearInvocations(pubkyService) @@ -977,10 +1516,10 @@ class PubkyRepoTest : BaseUnitTest() { loads.awaitAll() val attempts = if (fails) 2 else 1 - verify(pubkyService, times(attempts)).resolveContactProfile(VALID_SELF_KEY, true) + verify(pubkyService, times(attempts)).fetchPubkyProfileWithRevision(VALID_SELF_KEY) assertFalse(sut.isLoadingProfile.value) sut.loadProfile() - verify(pubkyService, times(attempts * 2)).resolveContactProfile(VALID_SELF_KEY, true) + verify(pubkyService, times(attempts * 2)).fetchPubkyProfileWithRevision(VALID_SELF_KEY) } } @@ -988,9 +1527,9 @@ class PubkyRepoTest : BaseUnitTest() { fun `profile load after a save does not reuse an outdated in-flight read`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) val finishLoad = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { finishLoad.await() - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Loaded")) + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Loaded")) } clearInvocations(pubkyService) @@ -1001,7 +1540,7 @@ class PubkyRepoTest : BaseUnitTest() { first.await() next.await() - verify(pubkyService, times(2)).resolveContactProfile(VALID_SELF_KEY, true) + verify(pubkyService, times(2)).fetchPubkyProfileWithRevision(VALID_SELF_KEY) assertEquals("Loaded", sut.profile.value?.name) } @@ -1009,9 +1548,9 @@ class PubkyRepoTest : BaseUnitTest() { fun `cancelled profile load leaves queued refresh available`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) val finishLoad = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { finishLoad.await() - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Loaded")) + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Loaded")) } clearInvocations(pubkyService) @@ -1021,7 +1560,7 @@ class PubkyRepoTest : BaseUnitTest() { finishLoad.complete(Unit) next.await() - verify(pubkyService, times(2)).resolveContactProfile(VALID_SELF_KEY, true) + verify(pubkyService, times(2)).fetchPubkyProfileWithRevision(VALID_SELF_KEY) assertEquals("Loaded", sut.profile.value?.name) assertFalse(sut.isLoadingProfile.value) } @@ -1039,8 +1578,8 @@ class PubkyRepoTest : BaseUnitTest() { val pk = checkNotNull(sut.publicKey.value) { "publicKey should be set after authentication" } val pubkyProfile = createPubkyProfile(name = "Cached Name", image = "pubky://cached_image") - whenever(pubkyService.resolveContactProfile(pk, true)) - .thenReturn(createResolution(pk, pubkyProfile = pubkyProfile)) + whenever(pubkyService.fetchPubkyProfileWithRevision(pk)) + .thenReturn(createSnapshot(pk, pubkyProfile)) sut.loadProfile() @@ -1058,10 +1597,10 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting(publicKey = VALID_SELF_KEY, profileName = "Old") val loadStarted = CompletableDeferred() val finishLoad = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { loadStarted.complete(Unit) finishLoad.await() - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Loaded")) + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Loaded")) } val load = async { sut.loadProfile() } loadStarted.await() @@ -1079,9 +1618,9 @@ class PubkyRepoTest : BaseUnitTest() { 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 { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { finishLoad.await() - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } assertFalse(sut.isLoadingProfile.value) @@ -1191,7 +1730,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(backupVersion + 1, sut.backupStateVersion.value) - verifyBlocking(pubkyService) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService) { publishPubkyProfile(any(), anyOrNull(), any()) } } @Test @@ -1497,7 +2036,7 @@ class PubkyRepoTest : BaseUnitTest() { importStarted.complete(Unit) finishImport.await() } - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) val initialize = async { sut.initialize() } importStarted.await() val disconnect = async(start = CoroutineStart.UNDISPATCHED) { sut.forgetUnrestoredIdentity() } @@ -1780,7 +2319,7 @@ class PubkyRepoTest : BaseUnitTest() { 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) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) sut.adoptRingIdentity(ringPubky) sut.restoreContactProfileOverrides(emptyMap()) @@ -1942,7 +2481,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `adoptRingIdentity should mark profile setup pending when the pubky has no profile`() = test { val ringPubky = stubRingCredential() whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn(null) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) val result = sut.adoptRingIdentity(ringPubky) @@ -1956,8 +2495,8 @@ class PubkyRepoTest : BaseUnitTest() { profileSetupPending.value = true val ringPubky = stubRingCredential() whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile())) val result = sut.adoptRingIdentity(ringPubky) @@ -1970,7 +2509,7 @@ class PubkyRepoTest : BaseUnitTest() { 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) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn(null) val result = sut.adoptRingIdentity(ringPubky) @@ -2019,8 +2558,8 @@ class PubkyRepoTest : BaseUnitTest() { 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)) }, + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).thenReturn( + case.remoteName?.let { createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = it)) }, ) val handoff = case.handoff(ringPubky) @@ -2029,7 +2568,7 @@ class PubkyRepoTest : BaseUnitTest() { 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) + verify(pubkyService, case.resolves.description(case.name)).fetchPubkyProfileWithRevision(VALID_SELF_KEY) } } @@ -2110,10 +2649,10 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) val profileLoadStarted = CompletableDeferred() val finishProfileLoad = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { profileLoadStarted.complete(Unit) finishProfileLoad.await() - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } clearInvocations(pubkyStore) val adoption = async { sut.adoptRingIdentity(ringPubky) } @@ -2152,7 +2691,7 @@ class PubkyRepoTest : BaseUnitTest() { Unit } val profileLoadStarted = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { profileLoadStarted.complete(Unit) awaitCancellation() } @@ -2192,7 +2731,7 @@ class PubkyRepoTest : BaseUnitTest() { Unit } val profileLoadStarted = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { profileLoadStarted.complete(Unit) awaitCancellation() } @@ -2226,12 +2765,12 @@ class PubkyRepoTest : BaseUnitTest() { } val firstProfileLoadStarted = CompletableDeferred() var profileLoads = 0 - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { if (++profileLoads == 1) { firstProfileLoadStarted.complete(Unit) awaitCancellation() } - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } val firstPick = async { sut.adoptRingIdentity(ringPubky) } firstProfileLoadStarted.await() @@ -2266,7 +2805,7 @@ class PubkyRepoTest : BaseUnitTest() { Unit } val profileLoadStarted = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { profileLoadStarted.complete(Unit) awaitCancellation() } @@ -2303,7 +2842,7 @@ class PubkyRepoTest : BaseUnitTest() { Unit } val profileLoadStarted = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { profileLoadStarted.complete(Unit) awaitCancellation() } @@ -2329,10 +2868,10 @@ class PubkyRepoTest : BaseUnitTest() { val contactsLoadStarted = CompletableDeferred() val finishOldProfileLoad = CompletableDeferred() val finishOldContactsLoad = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(oldPublicKey, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(oldPublicKey)).doSuspendableAnswer { profileLoadStarted.complete(Unit) finishOldProfileLoad.await() - createResolution(oldPublicKey, pubkyProfile = createPubkyProfile(name = "Old Profile")) + createSnapshot(oldPublicKey, createPubkyProfile(name = "Old Profile")) } whenever(pubkyService.contactRecords()).doSuspendableAnswer { if (sut.publicKey.value == oldPublicKey) { @@ -2353,8 +2892,8 @@ class PubkyRepoTest : BaseUnitTest() { whenever(sharedPubkyClient.ringCredential(ringPubky)).thenReturn(Result.success("new_ring_secret")) whenever(pubkyService.publicKeyFromSecret("new_ring_secret")).thenReturn(ringPubky) whenever(pubkyService.signIn("new_ring_secret")).thenReturn(Unit) - whenever(pubkyService.resolveContactProfile(newPublicKey, true)) - .thenReturn(createResolution(newPublicKey, pubkyProfile = createPubkyProfile(name = "New Profile"))) + whenever(pubkyService.fetchPubkyProfileWithRevision(newPublicKey)) + .thenReturn(createSnapshot(newPublicKey, createPubkyProfile(name = "New Profile"))) val adoption = async { sut.adoptRingIdentity(ringPubky) } assertFalse(adoption.isCompleted) @@ -2431,8 +2970,8 @@ class PubkyRepoTest : BaseUnitTest() { val ringPubky = stubRingCredential() whenever(pubkyService.signIn("ring_secret")).thenReturn(Unit) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile())) val adoption = sut.adoptRingIdentity(ringPubky) finishListing.complete(Unit) sourceCheck.await() @@ -2495,9 +3034,9 @@ class PubkyRepoTest : BaseUnitTest() { val profileLoad = CompletableDeferred() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") whenever(pubkyService.importSession("saved_session")).thenReturn(VALID_SELF_KEY) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { profileLoad.await() - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } val repo = createSut() val waiter = async { repo.awaitInitialization() } @@ -2578,8 +3117,8 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) whenever(pubkyService.importSession(session)).thenReturn(unprefixedPublicKey) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, pubkyProfile)) sut.initialize() @@ -2628,13 +3167,13 @@ class PubkyRepoTest : BaseUnitTest() { } whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("saved-secret") whenever(pubkyService.publicKeyFromSecret("saved-secret")).thenReturn(VALID_SELF_KEY) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Alice"))) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Alice"))) sut = createSut() val load = launch { sut.loadProfile() } runCurrent() - verify(pubkyService, never()).resolveContactProfile(VALID_SELF_KEY, true) + verify(pubkyService, never()).fetchPubkyProfileWithRevision(VALID_SELF_KEY) importGate.complete(Unit) load.join() advanceUntilIdle() @@ -2642,7 +3181,7 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(VALID_SELF_KEY, sut.publicKey.value) assertEquals("Alice", sut.profile.value?.name) assertNull(sut.readOnlyProfile.value) - verify(pubkyService).resolveContactProfile(VALID_SELF_KEY, true) + verify(pubkyService).fetchPubkyProfileWithRevision(VALID_SELF_KEY) } @Test @@ -2653,10 +3192,10 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.publicKeyFromSecret("saved-secret")).thenReturn(VALID_SELF_KEY) val response = CompletableDeferred() var profileUnavailable = false - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { response.await() if (profileUnavailable) throw AppError("Profile temporarily unavailable") - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Alice")) + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Alice")) } sut.initialize() @@ -2691,8 +3230,8 @@ class PubkyRepoTest : BaseUnitTest() { fun `saved public profile uses the adopted Ring credential without authenticating`() = test { val owner = stubRingCredential() adoptedSource = "${SharedPubkyContract.RING_SOURCE_PREFIX}$owner" - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Alice"))) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Alice"))) sut.loadProfile() @@ -2709,9 +3248,9 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("saved-secret") whenever(pubkyService.publicKeyFromSecret("saved-secret")).thenReturn(VALID_SELF_KEY) val response = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { response.await() - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Alice")) + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Alice")) } sut.initialize() val load = launch { sut.loadProfile() } @@ -2743,7 +3282,7 @@ class PubkyRepoTest : BaseUnitTest() { sut.loadProfile() assertNull(sut.readOnlyProfile.value) - verify(pubkyService, never()).resolveContactProfile(VALID_SELF_KEY, true) + verify(pubkyService, never()).fetchPubkyProfileWithRevision(VALID_SELF_KEY) } @Test @@ -2938,8 +3477,8 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) whenever(pubkyService.importSession(session)).thenReturn(unprefixedPublicKey) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, pubkyProfile)) whenever(pubkyService.observedContactRecords(VALID_SELF_KEY)).thenAnswer { throw TestAppError("Offline") } sut.initialize() @@ -2959,8 +3498,8 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) whenever(pubkyService.signIn(secretKey)).thenReturn(Unit) whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(publicKey) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, pubkyProfile)) sut.initialize() @@ -2990,8 +3529,8 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) whenever(pubkyService.importSession(session)).thenAnswer { throw TestAppError("Expired") } whenever(sharedPubkyClient.listRingIdentities()).thenReturn(Result.success(persistentListOf(ringPubky))) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Ring User"))) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Ring User"))) sut = createSut() sut.awaitInitialization() @@ -3212,9 +3751,9 @@ class PubkyRepoTest : BaseUnitTest() { val restore = stubSavedSessionRestore() restore.answer = { VALID_SELF_KEY } val finishProfile = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { finishProfile.await() - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } var contactsLoaded = false whenever(pubkyService.contactRecords()).thenAnswer { @@ -3244,8 +3783,8 @@ class PubkyRepoTest : BaseUnitTest() { 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())) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile())) val contactsRequested = CompletableDeferred() val finishContacts = CompletableDeferred>() whenever(pubkyService.contactRecords()).doSuspendableAnswer { @@ -3312,8 +3851,8 @@ class PubkyRepoTest : BaseUnitTest() { signInStarted.complete(Unit) finishSignIn.await() } - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile())) val adoption = async { sut.adoptRingIdentity(ringPubky) } signInStarted.await() @@ -3357,8 +3896,8 @@ class PubkyRepoTest : BaseUnitTest() { signInStarted.complete(Unit) finishSignIn.await() } - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile())) val adoption = async { sut.adoptRingIdentity(ringPubky) } signInStarted.await() @@ -3416,10 +3955,10 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.importSession("saved_session")).thenReturn(VALID_SELF_KEY) val profileLoadStarted = CompletableDeferred() val finishProfileLoad = CompletableDeferred() - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)).doSuspendableAnswer { profileLoadStarted.complete(Unit) finishProfileLoad.await() - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } clearInvocations(pubkyStore) val restore = async { sut.restoreSessionIfNeeded() } @@ -4907,7 +5446,7 @@ class PubkyRepoTest : BaseUnitTest() { val session = "test_session" val currentPublicKey = "pubkyalice" whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) - whenever(pubkyService.uploadProfileAvatar(any(), any())).thenReturn("pubky://avatar") + whenever(pubkyService.uploadPubkyAppAvatar(any(), any(), any())).thenReturn("pubky://avatar") authenticateForTesting(publicKey = currentPublicKey, secret = session, profileName = "Alice") @@ -4915,7 +5454,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals("pubky://avatar", result.getOrNull()) - verifyBlocking(pubkyService) { uploadProfileAvatar(any(), any()) } + verifyBlocking(pubkyService) { uploadPubkyAppAvatar(any(), any(), any()) } } @Test @@ -4929,7 +5468,7 @@ class PubkyRepoTest : BaseUnitTest() { val result = sut.uploadAvatar(byteArrayOf(1, 2, 3)) assertTrue(result.isFailure) - verifyBlocking(pubkyService, never()) { uploadProfileAvatar(any(), any()) } + verifyBlocking(pubkyService, never()) { uploadPubkyAppAvatar(any(), any(), any()) } } @Test @@ -5038,6 +5577,8 @@ class PubkyRepoTest : BaseUnitTest() { val prefixedPublicKey = publicKey.ensurePubkyPrefixForTest() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(secret) whenever { pubkyService.importSession(secret) }.thenReturn(publicKey) + whenever { pubkyService.fetchPubkyProfileWithRevision(prefixedPublicKey) } + .thenReturn(createSnapshot(prefixedPublicKey, createPubkyProfile(name = profileName))) whenever { pubkyService.resolveContactProfile(prefixedPublicKey, true) } .thenReturn(createResolution(prefixedPublicKey, pubkyProfile = createPubkyProfile(name = profileName))) whenever { pubkyService.contactRecords() }.thenReturn(emptyList()) @@ -5089,9 +5630,9 @@ class PubkyRepoTest : BaseUnitTest() { } private fun gateNextProfileLoadStoreWrite(store: GatedPubkyStore, loadedName: String) { - whenever { pubkyService.resolveContactProfile(VALID_SELF_KEY, true) }.doSuspendableAnswer { + whenever { pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY) }.doSuspendableAnswer { store.gateNextUpdate = true - createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = loadedName)) + createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = loadedName)) } } @@ -5142,6 +5683,14 @@ class PubkyRepoTest : BaseUnitTest() { "pubkyauth://direct_signup?hs=homeserver&st=invite", ).getOrThrow() + private fun paykitRecord(profile: PaykitProfile) = PaykitProfileRecord( + publicKey = VALID_SELF_KEY, + profile = profile, + path = "/pub/paykit/profile.json", + revision = "paykit-revision", + updatedAt = "2026-01-01T00:00:00Z", + ) + private fun createPaykitProfile( name: String, bio: String = "", @@ -5173,6 +5722,13 @@ class PubkyRepoTest : BaseUnitTest() { publicContactLastError = null, ) + private fun createSnapshot(publicKey: String, profile: SdkPubkyProfile) = PubkyProfileSnapshot( + publicKey = publicKey, + profile = profile, + revision = "revision-${profile.name}", + fetchedAt = "2026-01-01T00:00:00Z", + ) + private fun createResolution( publicKey: String, paykitProfile: PaykitProfile? = null, diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 039aebc1bd..9f723fd546 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -44,6 +44,7 @@ import com.synonym.paykit.PublicContactPaymentResolution import com.synonym.paykit.PublicContactSharingPolicy import com.synonym.paykit.PublicPaymentResolutionStatus import com.synonym.paykit.paykitAuthorizerSessionCapabilities +import com.synonym.paykit.pubkyAppProfileSessionCapabilities import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred @@ -1961,6 +1962,7 @@ class PaykitSdkServiceTest { } mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> native.`when` { paykitAuthorizerSessionCapabilities() }.thenReturn("capabilities") + native.`when` { pubkyAppProfileSessionCapabilities() }.thenReturn("profile") val restoration = async { service.initializeAndImportSession("saved-session") } val read = async { service.resolveContactProfile(RING_PUBKY, true) } runCurrent() @@ -2016,6 +2018,7 @@ class PaykitSdkServiceTest { mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> native.`when` { paykitAuthorizerSessionCapabilities() }.thenReturn("capabilities") + native.`when` { pubkyAppProfileSessionCapabilities() }.thenReturn("profile") if (failure is CancellationException) { assertFailsWith { service.initializeAndImportSession("saved-session") } assertFailsWith { service.contactRecords() } @@ -2069,6 +2072,7 @@ class PaykitSdkServiceTest { mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> native.`when` { paykitAuthorizerSessionCapabilities() }.thenReturn("capabilities") + native.`when` { pubkyAppProfileSessionCapabilities() }.thenReturn("profile") assertSame(failure, service.initializeAndImportSession("saved-session").exceptionOrNull()) verify(sdk, times(1)).initialize() verify(sdk, never()).publishPaykitApp(any(), any()) @@ -3462,13 +3466,13 @@ class PaykitSdkServiceTest { cleared = (if (failRecovery) listOf(other) else keys).map { PrivatePaymentListSyncChange(it, 1uL, null) }, + acknowledged = emptyList(), failedToQueue = if (failRecovery) { listOf(PrivatePaymentListSyncChange(RING_PUBKY, null, null)) } else { emptyList() }, failedToDeliver = emptyList(), - acknowledged = emptyList(), ) } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt index 2d55fd108c..1267f7fcf1 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceWipeTest.kt @@ -9,6 +9,7 @@ import com.synonym.paykit.PubkySessionAccess import com.synonym.paykit.PubkySessionBootstrap import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.paykitAuthorizerSessionCapabilities +import com.synonym.paykit.pubkyAppProfileSessionCapabilities import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -206,6 +207,7 @@ class PaykitSdkServiceWipeTest { mockStatic(Class.forName("com.synonym.paykit.Paykit_androidKt")).use { native -> native.`when` { paykitAuthorizerSessionCapabilities() }.thenReturn("capabilities") + native.`when` { pubkyAppProfileSessionCapabilities() }.thenReturn("profile") val active = async(start = CoroutineStart.UNDISPATCHED) { service.importSession("active") } bootstrapStarted.await() val queued = async(start = CoroutineStart.UNDISPATCHED) { diff --git a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt index 9645c467ea..cca371bf28 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactSaveSessionChangeTest.kt @@ -6,6 +6,7 @@ import coil3.ImageLoader import com.synonym.paykit.ContactRecord import com.synonym.paykit.ProfileResolution import com.synonym.paykit.ProfileSource +import com.synonym.paykit.PubkyProfileSnapshot import com.synonym.paykit.PublicationStatus import io.ktor.client.HttpClient import kotlinx.coroutines.CompletableDeferred @@ -302,6 +303,14 @@ class ContactSaveSessionChangeTest : BaseUnitTest() { } whenever(pubkyService.resolveContactProfile(owner, true, PaykitReadLane.Interactive, null)) .thenReturn(ownerResolution(owner)) + whenever(pubkyService.fetchPubkyProfileWithRevision(owner)).thenReturn( + PubkyProfileSnapshot( + publicKey = owner, + profile = checkNotNull(ownerResolution(owner).pubkyProfile), + revision = "revision", + fetchedAt = "2026-01-01T00:00:00Z", + ) + ) whenever(pubkyService.contactRecords()).thenReturn(records) sdkContacts[owner] = records.map { it.publicKey }.toSet() repo.initialize() diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt index f9c005d101..83107c23de 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder @@ -24,6 +25,7 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileLink import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.ProfileEditBase import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError @@ -61,7 +63,7 @@ class EditProfileViewModelTest : BaseUnitTest() { advanceUntilIdle() verify(pubkyRepo, times(1)).forgetUnrestoredIdentity() verify(pubkyRepo, never()).deleteProfileWithSessionRetry() - verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) + verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), anyOrNull(), any()) assertFalse(sut.uiState.value.showDeleteDialog) sut.viewModelScope.cancel() @@ -130,7 +132,7 @@ class EditProfileViewModelTest : BaseUnitTest() { sut.showDeleteConfirmation() advanceUntilIdle() verify(pubkyRepo, times(1)).deleteProfileWithSessionRetry() - verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any()) + verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), anyOrNull(), any()) verify(pubkyRepo, never()).signOut() assertFalse(sut.uiState.value.showDeleteDialog) @@ -146,7 +148,9 @@ class EditProfileViewModelTest : BaseUnitTest() { val sut = createSut() advanceUntilIdle() val result = CompletableDeferred>() - whenever(pubkyRepo.saveProfile(any(), any(), any(), any(), any())).doSuspendableAnswer { result.await() } + whenever( + pubkyRepo.saveProfile(any(), any(), any(), any(), anyOrNull(), any()) + ).doSuspendableAnswer { result.await() } sut.save() advanceUntilIdle() @@ -160,7 +164,7 @@ class EditProfileViewModelTest : BaseUnitTest() { sut.disconnectProfile() sut.showDeleteConfirmation() advanceUntilIdle() - verify(pubkyRepo, times(1)).saveProfile(any(), any(), any(), any(), any()) + verify(pubkyRepo, times(1)).saveProfile(any(), any(), any(), any(), anyOrNull(), any()) verify(pubkyRepo, never()).deleteProfileWithSessionRetry() verify(pubkyRepo, never()).forgetUnrestoredIdentity() assertFalse(sut.uiState.value.showDeleteDialog) @@ -400,6 +404,7 @@ class EditProfileViewModelTest : BaseUnitTest() { } whenever { pubkyRepo.forgetUnrestoredIdentity() }.thenReturn(Result.success(false)) whenever(pubkyRepo.profile).thenReturn(MutableStateFlow(createProfile())) + whenever(pubkyRepo.profileEditBase()).thenReturn(ProfileEditBase(createProfile(), "revision")) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(TEST_PUBLIC_KEY)) whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } .thenReturn(Result.success(Unit)) diff --git a/changelog.d/next/1801.added.md b/changelog.d/next/1801.added.md new file mode 100644 index 0000000000..0a48534fc2 --- /dev/null +++ b/changelog.d/next/1801.added.md @@ -0,0 +1 @@ +Bitkit now uses your pubky.app profile, so edits in Bitkit and on pubky.app show in both. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c9f42e1e39..a35b9162f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,7 @@ barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17 biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc11" } breez-sdk-spark = { module = "breez_sdk_spark:bindings-android", version = "0.28.0" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc6" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc7" } bouncycastle-pkix-jdk = { module = "org.bouncycastle:bcpkix-jdk18on", version = "1.86" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.86" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } diff --git a/journeys/profile/README.md b/journeys/profile/README.md index a149edc490..9252188444 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -50,4 +50,6 @@ For controlled fault testing, interrupt deletion after private withdrawal but be `create-profile.xml` records the name-only saving interval and checks the saved name after reopening and relaunching. Use a fresh unfunded identity per run and record the exact app/SDK revisions and network. With controlled network fault injection, block direct UDP while keeping the configured HTTP relays and homeserver reachable; saving should still complete. Reject the required profile write separately and verify saving reports failure instead of navigating to Pay Contacts. Fault injection is a manual check beyond journey-runner capabilities. +`edit-pubky-app-profile.xml` checks that Edit Profile presents the profile as the public pubky.app profile and that a name edit is saved and survives a relaunch. Use an identity whose pubky.app profile you can open on pubky.app; checking the edit there, and an edit made in pubky.app while Bitkit is open, are manual checks beyond journey-runner capabilities. + On Android, the restart check waits for the full profile (`ProfileViewName`). A matching `ProfileCachedName` during loading does not complete that check. Record an unfinished load separately from a loaded profile with a missing or incorrect name. diff --git a/journeys/profile/edit-pubky-app-profile.xml b/journeys/profile/edit-pubky-app-profile.xml new file mode 100644 index 0000000000..ee1913eba2 --- /dev/null +++ b/journeys/profile/edit-pubky-app-profile.xml @@ -0,0 +1,18 @@ + + + Precondition: a disposable wallet signed in to a Pubky profile, PIN off and an English device. + This checks that Edit Profile presents the profile as the user's public pubky.app profile and + that a name change is saved and shown again after a relaunch. Whether the change also reached + pubky.app is checked outside the app, under the PR's manual tests. + + + Open Profile using "ProfileButton" and wait for "ProfileViewName" + Tap "ProfileEdit" and wait for "ProfileEditName" + Verify the footer note says this is your pubky.app profile and that it is public + Replace the name with "Edited In Bitkit" and dismiss the keyboard + Tap "ProfileEditSave" and wait for "ProfileUpdatedToast" + Verify "ProfileViewName" shows "Edited In Bitkit" + Relaunch the app, open Profile using "ProfileButton" and wait for the full profile name (id "ProfileViewName", predicate exists) + Verify "ProfileViewName" shows "Edited In Bitkit" + +