From e10b071100a0c81e72629b6664c5f6511f16f759 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 17:22:43 +0100 Subject: [PATCH 01/22] feat: use the pubky.app profile directly --- .../java/to/bitkit/models/PubkyProfile.kt | 6 + .../java/to/bitkit/repositories/PubkyRepo.kt | 166 ++++++++---- .../to/bitkit/services/PaykitSdkService.kt | 30 ++- .../java/to/bitkit/services/PubkyService.kt | 18 ++ .../ui/screens/profile/CreateProfileScreen.kt | 3 +- .../screens/profile/CreateProfileViewModel.kt | 5 +- .../ui/screens/profile/EditProfileScreen.kt | 3 +- .../screens/profile/EditProfileViewModel.kt | 18 +- app/src/main/res/values-ar/strings.xml | 3 +- app/src/main/res/values-b+es+419/strings.xml | 3 +- app/src/main/res/values-ca/strings.xml | 3 +- app/src/main/res/values-cs/strings.xml | 3 +- app/src/main/res/values-de/strings.xml | 3 +- app/src/main/res/values-el/strings.xml | 3 +- app/src/main/res/values-es-rES/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 3 +- app/src/main/res/values-fr/strings.xml | 3 +- app/src/main/res/values-it/strings.xml | 3 +- app/src/main/res/values-nl/strings.xml | 3 +- app/src/main/res/values-pl/strings.xml | 3 +- app/src/main/res/values-pt-rBR/strings.xml | 3 +- app/src/main/res/values-pt/strings.xml | 3 +- app/src/main/res/values-ru/strings.xml | 3 +- app/src/main/res/values/strings.xml | 3 +- .../repositories/PaykitIssuerInteropTest.kt | 2 + .../PaykitPaymentProofRepoTest.kt | 2 + ...aykitPaymentRequestRepoSubscriptionTest.kt | 2 + .../PaykitPaymentRequestRepoTest.kt | 3 + .../repositories/PaykitUsdtPaymentRepoTest.kt | 2 + .../repositories/PrivatePaykitRepoTest.kt | 2 + .../to/bitkit/repositories/PubkyRepoTest.kt | 255 +++++++++++------- .../bitkit/services/PaykitSdkServiceTest.kt | 13 +- .../services/PaykitSdkServiceWipeTest.kt | 2 + gradle/libs.versions.toml | 2 +- journeys/profile/README.md | 2 + journeys/profile/edit-pubky-app-profile.xml | 18 ++ 36 files changed, 435 insertions(+), 167 deletions(-) create mode 100644 journeys/profile/edit-pubky-app-profile.xml diff --git a/app/src/main/java/to/bitkit/models/PubkyProfile.kt b/app/src/main/java/to/bitkit/models/PubkyProfile.kt index d5e846c802..f8affc604a 100644 --- a/app/src/main/java/to/bitkit/models/PubkyProfile.kt +++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt @@ -10,6 +10,12 @@ 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 + +fun isValidProfileName(name: String) = name.trim().length in PROFILE_NAME_MIN_LENGTH..PROFILE_NAME_MAX_LENGTH + @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..080f560910 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -7,7 +7,11 @@ import coil3.ImageLoader import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate import com.synonym.paykit.ProfileResolution +import com.synonym.paykit.PaykitException import com.synonym.paykit.PubkyAuthCompanionClaim +import com.synonym.paykit.PubkyProfileSnapshot +import com.synonym.paykit.PubkyProfileLink as SdkPubkyProfileLink +import com.synonym.paykit.PubkyProfileUpdate import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post @@ -110,6 +114,9 @@ private fun Throwable.isPaykitReadTimeout(): Boolean = data object PubkyAlreadySignedInError : AppError("Already signed in") +/** 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) + /** Whether a Pubky identity is ready to use once restores already under way have finished. */ enum class PubkyIdentityReadiness { /** A session is active. */ @@ -160,6 +167,13 @@ 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 const val PUBKY_APP_LINK_TITLE_MAX_LENGTH = 100 + private const val PUBKY_APP_AVATAR_MAX_BYTES = 5_242_880uL } private val scope = appScope(ioDispatcher, TAG) @@ -185,6 +199,10 @@ class PubkyRepo @Inject constructor( private val adoptedSourceCheckMutex = Mutex() private val adoptionMutex = Mutex() private val profileWriteGeneration = AtomicLong(0L) + + /** Revision of the loaded pubky.app profile, which a save must still match; null when none exists. */ + @Volatile + private var ownProfileRevision: String? = null private val signInGeneration = AtomicLong(0L) private val storedIdentityRevision = AtomicLong(0L) private val readOnlyProfileGeneration = AtomicLong(0L) @@ -807,8 +825,7 @@ class PubkyRepo @Inject constructor( try { runSuspendCatching { withContext(ioDispatcher) { - resolveContactProfile(signIn.publicKey, retry = true).getOrThrow() - ?: throw AppError("Profile not found") + loadOwnProfile(signIn.publicKey) ?: throw AppError("Profile not found") } }.onSuccess { loadedProfile -> var isCurrent = false @@ -846,7 +863,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 } @@ -966,7 +984,9 @@ class PubkyRepo @Inject constructor( avatarBytes: ByteArray?, ): String? { val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } - writeProfile(name, bio, links, tags, imageUrl) + val publicKey = requireNotNull(pubkyService.currentPublicKey()) { "No active Pubky session" }.ensurePubkyPrefix() + val existingRevision = pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision + writeProfile(name, bio, links, tags, imageUrl, base = null, expectedRevision = existingRevision) return imageUrl } @@ -1030,7 +1050,7 @@ class PubkyRepo @Inject constructor( "No session available" } val compressed = compressAvatar(imageBytes) - pubkyService.uploadProfileAvatar(compressed, contentType = "image/jpeg") + withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(compressed, contentType = "image/jpeg") } } } @@ -1045,18 +1065,19 @@ class PubkyRepo @Inject constructor( 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, - ) + val snapshot = runSuspendCatching { + writeProfile(name, bio, links, tags, imageUrl, base = _profile.value, expectedRevision = ownProfileRevision) + }.getOrElse { + if (generateSequence(it) { error -> error.cause }.none { error -> error is PaykitException.ConcurrentUpdate }) { + throw it + } + scope.launch { loadProfile() } + throw PubkyProfileChangedError(it) + } + val profile = PubkyProfile.fromPubkyProfile(pk, snapshot.profile).copy(tags = tags) setProfile(profile) + ownProfileRevision = snapshot.revision cacheMetadata(profile) notifyBackupStateChanged() } @@ -1124,17 +1145,74 @@ class PubkyRepo @Inject constructor( links: List, tags: List, imageUrl: String?, - ) { - val data = PubkyProfile( - publicKey = "", - name = name, - bio = bio, - imageUrl = imageUrl, - links = links, - tags = tags, - status = null, - ).toProfileData() - pubkyService.publishPaykitProfile(data.toPaykitProfile()) + base: PubkyProfile?, + expectedRevision: String?, + ): PubkyProfileSnapshot { + val update = PubkyProfileUpdate( + name = name.takeIf { it != base?.name }, + bio = bio.takeIf { base == null || it != base.bio }, + image = when { + 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 = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision) } + ownProfileRevision = snapshot.revision + publishPaykitProfileCopy(PubkyProfile.fromPubkyProfile("", snapshot.profile).copy(tags = tags)) + return snapshot + } + + private suspend fun loadOwnProfile(publicKey: String): PubkyProfile? { + val paykitData = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) } + .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } + .getOrNull() + ?.let { PubkyProfileData.fromPaykitProfile(it.profile) } + val snapshot = readRetryingOnce(publicKey) { pubkyService.fetchPubkyProfileWithRevision(publicKey) } + ?: paykitData?.let { migrateToPubkyApp(it) } + ownProfileRevision = snapshot?.revision + snapshot ?: return null + val profile = PubkyProfile.fromPubkyProfile(publicKey, snapshot.profile).copy(tags = paykitData?.tags.orEmpty()) + if (paykitData != null && paykitData != profile.toProfileData()) publishPaykitProfileCopy(profile) + return profile + } + + private suspend fun migrateToPubkyApp(data: PubkyProfileData): PubkyProfileSnapshot? = runSuspendCatching { + val image = data.image?.let { migrateAvatar(it) } + val update = PubkyProfileUpdate( + name = data.name.trim().take(PUBKY_APP_NAME_MAX_LENGTH), + bio = data.bio.trim().take(PUBKY_APP_BIO_MAX_LENGTH), + image = image, + links = data.links + .filter { it.label.isNotBlank() && it.url.isNotBlank() } + .take(PUBKY_APP_LINKS_MAX_COUNT) + .map { SdkPubkyProfileLink(title = it.label.trim().take(PUBKY_APP_LINK_TITLE_MAX_LENGTH), url = it.url.trim()) }, + ) + withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision = null) } + .also { Logger.info("Moved Bitkit profile to pubky.app", context = TAG) } + }.onFailure { Logger.warn("Failed to move Bitkit profile to pubky.app", it, context = TAG) }.getOrNull() + + private suspend fun migrateAvatar(uri: String): String? = 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" + } + withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(bytes, contentType) } + }.onFailure { Logger.warn("Failed to move avatar to pubky.app", it, context = TAG) }.getOrNull() + + private suspend fun publishPaykitProfileCopy(profile: PubkyProfile) { + runSuspendCatching { pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile()) } + .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } + } + + 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 +2076,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 +2216,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 f90d7e2f7c..9466ffcc9f 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 @@ -83,6 +85,7 @@ import com.synonym.paykit.defaultConfig import com.synonym.paykit.defaultPubkyClientConfig import com.synonym.paykit.parsePubkyAuthUrl import com.synonym.paykit.paykitAuthorizerSessionCapabilities +import com.synonym.paykit.pubkyAppProfileSessionCapabilities import com.synonym.paykit.pubkyPublicKeyFromSecret import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic import dagger.hilt.android.qualifiers.ApplicationContext @@ -667,6 +670,26 @@ 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?): PubkyProfileSnapshot { + isSetup.await() + return operationLock.withLock { + completeSdkCall { handle().publishPubkyProfile(update, expectedRevision) } + } + } + + suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String): String { + isSetup.await() + return operationLock.withLock { + completeSdkCall { handle().uploadPubkyAppAvatar(bytes, contentType) } + } + } + suspend fun fetchPubkyFollows(publicKey: String): List = publicRead { it.fetchPubkyFollows(publicKey, maxEntries = 10_000u) } @@ -884,7 +907,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, diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 413fb3c90c..b0968a6a72 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 @@ -211,6 +214,21 @@ 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?): PubkyProfileSnapshot = + ServiceQueue.CORE.background { paykitSdkService.publishPubkyProfile(update, expectedRevision) } + + suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String): String = ServiceQueue.CORE.background { + paykitSdkService.uploadPubkyAppAvatar(bytes, contentType) + } + suspend fun getContacts(publicKey: String): List = cancellablePublicRead { paykitSdkService.fetchPubkyFollows(publicKey) } 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..86c5ec8368 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 @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -17,13 +18,13 @@ 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.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger -import javax.inject.Inject @HiltViewModel class CreateProfileViewModel @Inject constructor( @@ -125,7 +126,7 @@ class CreateProfileViewModel @Inject constructor( } fun onNameChange(name: String) { - _uiState.update { it.copy(name = name) } + _uiState.update { it.copy(name = name.take(PROFILE_NAME_MAX_LENGTH)) } } fun onBioChange(bio: String) { 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..813de2bcf3 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,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.models.isValidProfileName import to.bitkit.ui.components.AddLinkSheet import to.bitkit.ui.components.AddTagSheet import to.bitkit.ui.components.GradientCircularProgressIndicator @@ -166,7 +167,7 @@ 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, 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..1f8c760749 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 @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -22,14 +23,15 @@ 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.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyProfileChangedError import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger -import javax.inject.Inject @HiltViewModel class EditProfileViewModel @Inject constructor( @@ -78,6 +80,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 +107,7 @@ class EditProfileViewModel @Inject constructor( } fun onNameChange(name: String) { - _uiState.update { it.copy(name = name) } + _uiState.update { it.copy(name = name.take(PROFILE_NAME_MAX_LENGTH)) } } fun onBioChange(bio: String) { @@ -207,11 +217,13 @@ 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 = if (changed) context.getString(R.string.profile__edit_changed_error) else it.message, ) + if (changed) reloadAfterExternalEdit() } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7157df23d3..dc8c902d66 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1309,10 +1309,11 @@ فشل فصل الملف الشخصي النبذة ملاحظة قصيرة. أخبرنا قليلاً عن نفسك. + تغيّر ملفك الشخصي في pubky.app. حمّل Bitkit أحدث نسخة، لذا أعد إجراء تعديلك. حذف اسمك تعديل الملف الشخصي - يرجى ملاحظة أن معلومات الملف الشخصي مخزنة في ملفات عامة. + هذا هو ملفك الشخصي على pubky.app. إنه عام، والتغييرات التي تجريها هنا تظهر أيضًا على pubky.app. فشل حفظ الملف الشخصي تم حفظ الملف الشخصي العلامات 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 62bd0a0f1b..b7fb2b2cab 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1335,9 +1335,10 @@ 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 diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index c6f9c388b9..61dfa7dc16 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1331,9 +1331,10 @@ 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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b6bd8d3b17..d4fa92fdf1 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1333,9 +1333,10 @@ 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b45acef595..79def64fcd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1331,9 +1331,10 @@ 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 diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index be27aee396..0585269ea1 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1315,10 +1315,11 @@ Αποτυχία αποσύνδεσης προφίλ ΒΙΟΓΡΑΦΙΚΟ Σύντομη σημείωση. Πες λίγα λόγια για τον εαυτό σου. + Το προφίλ σου άλλαξε στο pubky.app. Το Bitkit φόρτωσε την πιο πρόσφατη έκδοση, οπότε κάνε ξανά την αλλαγή σου. ΔΙΑΓΡΑΦΗ ΤΟ ΟΝΟΜΑ ΣΟΥ Επεξεργασία προφίλ - Τα στοιχεία του προφίλ αποθηκεύονται σε δημόσια αρχεία. + Αυτό είναι το προφίλ σου στο pubky.app. Είναι δημόσιο, και οι αλλαγές που κάνεις εδώ εμφανίζονται και στο pubky.app. Αποτυχία αποθήκευσης προφίλ Το προφίλ αποθηκεύτηκε ΕΤΙΚΕΤΕΣ diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index d51da65431..9007c0d1a8 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1329,9 +1329,10 @@ 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c67b788e3c..571ac39585 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1328,9 +1328,10 @@ 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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b9d86083d3..42f0cbaa11 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1329,9 +1329,10 @@ É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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1b9544f4db..a8505052c9 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1334,9 +1334,10 @@ 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 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 60745893b4..4d27460f6c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1331,9 +1331,10 @@ 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 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 43b6cde23b..9d626d5926 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1332,9 +1332,10 @@ 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1f58c229a5..17b79e661d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1334,9 +1334,10 @@ 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 diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a157ac38fe..b83953fd9a 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1308,10 +1308,11 @@ 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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8415b1d10d..edcf94b7d4 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1349,9 +1349,10 @@ Не удалось отключить профиль О СЕБЕ Короткая заметка. Расскажите немного о себе. + Ваш профиль изменился в pubky.app. Bitkit загрузил последнюю версию, поэтому внесите изменение снова. УДАЛЕНИЕ ВАШЕ ИМЯ - Обратите внимание: данные профиля хранятся в публичных файлах. + Это ваш профиль pubky.app. Он публичный, и изменения, которые вы вносите здесь, также видны в pubky.app. Не удалось сохранить профиль Профиль сохранён ТЕГИ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49512cdbbe..808d19d002 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -642,10 +642,11 @@ 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 diff --git a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt index a5febcda89..4c1ef3e79d 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt @@ -135,6 +135,7 @@ class PaykitIssuerInteropTest { ), acceptedEventId = null, acceptedOutboundStatus = null, + acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, @@ -147,6 +148,7 @@ class PaykitIssuerInteropTest { lastEventAt = NOW.toString(), invalidReason = null, proposalAppId = "bitkit", + proposalRecordedAt = null, payerAppId = null, executionClaimAppId = null, ) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index c6f146bb78..ba0ae29a24 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -3652,6 +3652,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), acceptedEventId = null, acceptedOutboundStatus = null, + acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, @@ -3664,6 +3665,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastEventAt = "2027-01-15T08:00:00Z", invalidReason = null, proposalAppId = "bitkit", + proposalRecordedAt = null, payerAppId = null, executionClaimAppId = null, ) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index e51e73d959..e1ab27e870 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -1655,6 +1655,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), acceptedEventId = null, acceptedOutboundStatus = null, + acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, @@ -1667,6 +1668,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat lastEventAt = clock.now().toString(), invalidReason = null, proposalAppId = "bitkit", + proposalRecordedAt = null, payerAppId = null, executionClaimAppId = null, ) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 148d9dc9ae..e7509d7820 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -1049,6 +1049,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).let { it.copy( proposalAppId = "marketplace", + proposalRecordedAt = null, terms = requireNotNull(it.terms).copy( acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue), paymentEndpoints = mapOf( @@ -3650,6 +3651,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), acceptedEventId = null, acceptedOutboundStatus = null, + acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, @@ -3662,6 +3664,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastEventAt = clock.now().toString(), invalidReason = null, proposalAppId = "bitkit", + proposalRecordedAt = null, payerAppId = null, executionClaimAppId = null, ) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt index e4f2795f08..e6ac738bd8 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt @@ -804,6 +804,7 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { proposalStreamItemId = 1u, proposalOutboundMessageId = null, proposalOutboundStatus = null, proposalEventId = "proposal", proposalAppId = "bitkit", + proposalRecordedAt = null, payerAppId = "bitkit", executionClaimAppId = "bitkit", terms = PaymentRequestTerms( @@ -818,6 +819,7 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { mock() ), acceptedEventId = "accepted", acceptedOutboundStatus = null, + acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, canceledOutboundStatus = null, paymentProofs = listOf( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index fb17b7088c..ad1388b751 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -4895,6 +4895,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { error = null, ) }, + acknowledged = emptyList(), failedToQueue = failedToQueue, failedToDeliver = failedToDeliver, ) @@ -4912,6 +4913,7 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cleared = updates .filter { it.reservations.isEmpty() } .map { privateListSyncChange(it.counterparty) }, + acknowledged = emptyList(), failedToQueue = emptyList(), failedToDeliver = emptyList(), ) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4aecb53aac..6460549820 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -8,12 +8,16 @@ 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.PubkyProfile as SdkPubkyProfile +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 @@ -24,6 +28,18 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart @@ -89,18 +105,6 @@ import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.test.forEachCase import to.bitkit.utils.AppError -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertSame -import kotlin.test.assertTrue -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime -import kotlin.time.Instant -import com.synonym.paykit.PubkyProfile as SdkPubkyProfile @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) class PubkyRepoTest : BaseUnitTest() { @@ -155,6 +159,20 @@ class PubkyRepoTest : BaseUnitTest() { Unit } whenever(pubkyService.contactRecords()).thenReturn(emptyList()) + whenever(pubkyService.currentPublicKey()).thenReturn(VALID_SELF_KEY) + whenever { pubkyService.publishPubkyProfile(any(), anyOrNull()) }.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)) } @@ -767,8 +785,8 @@ class PubkyRepoTest : BaseUnitTest() { 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.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile())) whenever(pubkyService.contactRecords()).doSuspendableAnswer { contactsLoadStarted.complete(Unit) finishContactsLoad.await() @@ -818,7 +836,7 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting(publicKey = VALID_SELF_KEY) profileSetupPending.value = true whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) .thenReturn(null) clearInvocations(pubkyService) @@ -826,7 +844,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 @@ -880,8 +898,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() @@ -893,6 +911,54 @@ 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") + } + assertEquals("New", 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())) + .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, + ) + } + assertEquals("Bitkit Name", sut.profile.value?.name) + } + @Test fun `loadProfile should keep existing profile on failure`() = test { authenticateForTesting() @@ -900,7 +966,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() @@ -911,13 +977,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 @@ -926,10 +992,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) @@ -939,10 +1005,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) } } @@ -950,9 +1016,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) @@ -963,7 +1029,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) } @@ -971,9 +1037,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) @@ -983,7 +1049,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) } @@ -1001,8 +1067,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() @@ -1020,10 +1086,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() @@ -1041,9 +1107,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) @@ -1459,7 +1525,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() } @@ -1742,7 +1808,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()) @@ -1904,7 +1970,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) @@ -1918,8 +1984,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) @@ -1932,7 +1998,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) @@ -1981,8 +2047,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) @@ -1991,7 +2057,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) } } @@ -2072,10 +2138,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) } @@ -2114,7 +2180,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() } @@ -2154,7 +2220,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() } @@ -2188,12 +2254,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() @@ -2228,7 +2294,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() } @@ -2265,7 +2331,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() } @@ -2393,8 +2459,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() @@ -2457,9 +2523,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() } @@ -2540,8 +2606,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() @@ -2590,13 +2656,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() @@ -2604,7 +2670,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 @@ -2615,10 +2681,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() @@ -2653,8 +2719,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() @@ -2671,9 +2737,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() } @@ -2705,7 +2771,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 @@ -2900,8 +2966,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() @@ -2921,8 +2987,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() @@ -2952,8 +3018,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() @@ -3174,9 +3240,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 { @@ -3206,8 +3272,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 { @@ -3274,8 +3340,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() @@ -3319,8 +3385,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() @@ -3378,10 +3444,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() } @@ -5000,8 +5066,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.resolveContactProfile(prefixedPublicKey, true) } - .thenReturn(createResolution(prefixedPublicKey, pubkyProfile = createPubkyProfile(name = profileName))) + whenever { pubkyService.fetchPubkyProfileWithRevision(prefixedPublicKey) } + .thenReturn(createSnapshot(prefixedPublicKey, createPubkyProfile(name = profileName))) whenever { pubkyService.contactRecords() }.thenReturn(emptyList()) sut.initialize() @@ -5051,9 +5117,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)) } } @@ -5135,6 +5201,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 cee385b416..0db2731906 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 @@ -226,7 +227,7 @@ class PaykitSdkServiceTest { } "revision" } - val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()) + val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any())).thenReturn(report) val updates = listOf(PrivatePaymentListReservationUpdateInput(RING_PUBKY, listOf(mock()))) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } @@ -1955,6 +1956,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() @@ -2010,6 +2012,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() } @@ -2063,6 +2066,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()) @@ -2519,7 +2523,7 @@ class PaykitSdkServiceTest { withdrawal.thenThrow(IllegalStateException("network unavailable")) } else { withdrawal.thenReturn( - PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()), + PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()), ) } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } @@ -3355,7 +3359,7 @@ class PaykitSdkServiceTest { val sdk = mock() val started = CompletableDeferred() val resume = CompletableDeferred() - val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()) + val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) whenever(sdk.linkedPeers()).thenReturn(listOf(contactPeer(LinkedPeerState.LINKED))) whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any())).doSuspendableAnswer { started.complete(Unit) @@ -3415,7 +3419,7 @@ class PaykitSdkServiceTest { whenever(sdk.paykitAppRegistry(RING_PUBKY)).thenReturn( PaykitAppRegistry(1u, null, listOf(PaykitApp("bitkit", "Bitkit", capabilities)), null, emptyMap()), ) - val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()) + val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) val updates = listOf(RING_PUBKY, other).map { PrivatePaymentListReservationUpdateInput(it, emptyList()) } whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(updates, false)).thenReturn(report) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } @@ -3456,6 +3460,7 @@ 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 { 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index b860617b5c..4270fe01f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.8.0" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.8.0-rc11" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.1-rc2" } +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" + + From 4da77f0e7098d804cbe19847512403651f6a78a1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 17:52:14 +0100 Subject: [PATCH 02/22] fix: keep the newest profile revision and move profiles text first --- .../java/to/bitkit/repositories/PubkyRepo.kt | 64 ++++++++++----- .../screens/profile/CreateProfileViewModel.kt | 2 +- .../screens/profile/EditProfileViewModel.kt | 2 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 77 +++++++++++-------- .../bitkit/services/PaykitSdkServiceTest.kt | 3 +- .../contacts/ContactSaveSessionChangeTest.kt | 9 +++ 6 files changed, 101 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 080f560910..cce4724813 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -6,11 +6,10 @@ import androidx.core.graphics.scale import coil3.ImageLoader import com.synonym.paykit.ContactRecord import com.synonym.paykit.ContactUpdate -import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PaykitException +import com.synonym.paykit.ProfileResolution import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyProfileSnapshot -import com.synonym.paykit.PubkyProfileLink as SdkPubkyProfileLink import com.synonym.paykit.PubkyProfileUpdate import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -69,6 +68,7 @@ import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyModerationBot import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData +import to.bitkit.models.PubkyProfileDataLink import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkySessionBackupKind @@ -79,6 +79,7 @@ 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.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -88,6 +89,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") @@ -173,6 +175,7 @@ class PubkyRepo @Inject constructor( private const val PUBKY_APP_BIO_MAX_LENGTH = 160 private const val PUBKY_APP_LINKS_MAX_COUNT = 5 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 } @@ -827,7 +830,7 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { loadOwnProfile(signIn.publicKey) ?: throw AppError("Profile not found") } - }.onSuccess { loadedProfile -> + }.onSuccess { (loadedProfile, revision) -> var isCurrent = false _profile.update { isCurrent = isCurrentLoad() @@ -837,6 +840,7 @@ 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 { @@ -984,9 +988,16 @@ class PubkyRepo @Inject constructor( avatarBytes: ByteArray?, ): String? { val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } - val publicKey = requireNotNull(pubkyService.currentPublicKey()) { "No active Pubky session" }.ensurePubkyPrefix() - val existingRevision = pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision - writeProfile(name, bio, links, tags, imageUrl, base = null, expectedRevision = existingRevision) + runSuspendCatching { + writeProfile(name, bio, links, tags, imageUrl, base = null, expectedRevision = null) + }.getOrElse { + if (!it.isConcurrentUpdate()) throw it + val publicKey = requireNotNull( + pubkyService.currentPublicKey() + ) { "No active Pubky session" }.ensurePubkyPrefix() + val existingRevision = pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision + writeProfile(name, bio, links, tags, imageUrl, base = null, expectedRevision = existingRevision) + } return imageUrl } @@ -1067,11 +1078,17 @@ class PubkyRepo @Inject constructor( } val pk = requireNotNull(_publicKey.value) { "No public key available" } val snapshot = runSuspendCatching { - writeProfile(name, bio, links, tags, imageUrl, base = _profile.value, expectedRevision = ownProfileRevision) + writeProfile( + name, + bio, + links, + tags, + imageUrl, + base = _profile.value, + expectedRevision = ownProfileRevision + ) }.getOrElse { - if (generateSequence(it) { error -> error.cause }.none { error -> error is PaykitException.ConcurrentUpdate }) { - throw it - } + if (!it.isConcurrentUpdate()) throw it scope.launch { loadProfile() } throw PubkyProfileChangedError(it) } @@ -1165,35 +1182,44 @@ class PubkyRepo @Inject constructor( return snapshot } - private suspend fun loadOwnProfile(publicKey: String): PubkyProfile? { + private suspend fun loadOwnProfile(publicKey: String): Pair? { val paykitData = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) } .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } .getOrNull() ?.let { PubkyProfileData.fromPaykitProfile(it.profile) } val snapshot = readRetryingOnce(publicKey) { pubkyService.fetchPubkyProfileWithRevision(publicKey) } ?: paykitData?.let { migrateToPubkyApp(it) } - ownProfileRevision = snapshot?.revision snapshot ?: return null val profile = PubkyProfile.fromPubkyProfile(publicKey, snapshot.profile).copy(tags = paykitData?.tags.orEmpty()) if (paykitData != null && paykitData != profile.toProfileData()) publishPaykitProfileCopy(profile) - return profile + return profile to snapshot.revision } private suspend fun migrateToPubkyApp(data: PubkyProfileData): PubkyProfileSnapshot? = runSuspendCatching { - val image = data.image?.let { migrateAvatar(it) } val update = PubkyProfileUpdate( name = data.name.trim().take(PUBKY_APP_NAME_MAX_LENGTH), bio = data.bio.trim().take(PUBKY_APP_BIO_MAX_LENGTH), - image = image, + image = null, links = data.links - .filter { it.label.isNotBlank() && it.url.isNotBlank() } + .map { PubkyProfileDataLink(label = it.label.trim(), url = it.url.trim()) } + .filter { it.label.isNotEmpty() && it.url.isPubkyAppLinkUrl() } .take(PUBKY_APP_LINKS_MAX_COUNT) - .map { SdkPubkyProfileLink(title = it.label.trim().take(PUBKY_APP_LINK_TITLE_MAX_LENGTH), url = it.url.trim()) }, + .map { SdkPubkyProfileLink(title = it.label.take(PUBKY_APP_LINK_TITLE_MAX_LENGTH), url = it.url) }, ) - withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision = null) } - .also { Logger.info("Moved Bitkit profile to pubky.app", context = TAG) } + val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision = null) } + Logger.info("Moved Bitkit profile to pubky.app", context = TAG) + val image = data.image?.let { migrateAvatar(it) } ?: return@runSuspendCatching snapshot + withPubkyAppScope { + pubkyService.publishPubkyProfile(PubkyProfileUpdate(null, null, image, null), snapshot.revision) + } }.onFailure { Logger.warn("Failed to move Bitkit profile to pubky.app", it, context = TAG) }.getOrNull() + private fun Throwable.isConcurrentUpdate() = + generateSequence(this) { it.cause }.any { it is PaykitException.ConcurrentUpdate } + + private fun String.isPubkyAppLinkUrl() = + length <= PUBKY_APP_LINK_URL_MAX_LENGTH && runCatching { URI(this).scheme }.getOrNull() != null + private suspend fun migrateAvatar(uri: String): String? = runSuspendCatching { val bytes = pubkyService.fetchFile(uri, PUBKY_APP_AVATAR_MAX_BYTES) val contentType = when (uri.substringAfterLast('.').lowercase()) { 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 86c5ec8368..21936d472f 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 @@ -7,7 +7,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -25,6 +24,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import javax.inject.Inject @HiltViewModel class CreateProfileViewModel @Inject constructor( 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 1f8c760749..71a1c24ecf 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 @@ -9,7 +9,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -32,6 +31,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import javax.inject.Inject @HiltViewModel class EditProfileViewModel @Inject constructor( diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 6460549820..b885af435f 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -15,7 +15,6 @@ import com.synonym.paykit.ProfileResolution import com.synonym.paykit.ProfileSource import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PubkyAuthDetails -import com.synonym.paykit.PubkyProfile as SdkPubkyProfile import com.synonym.paykit.PubkyProfileSnapshot import com.synonym.paykit.PubkyProfileUpdate import com.synonym.paykit.PubkySessionBootstrapResult @@ -28,18 +27,6 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertSame -import kotlin.test.assertTrue -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime -import kotlin.time.Instant import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart @@ -105,6 +92,19 @@ import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.test.forEachCase import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import com.synonym.paykit.PubkyProfile as SdkPubkyProfile @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) class PubkyRepoTest : BaseUnitTest() { @@ -575,7 +575,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()) + ).thenAnswer { throw TestAppError("Publish failed") } whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } val result = sut.createIdentity( @@ -604,7 +606,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()) } } @Test @@ -641,7 +643,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()) } } @Test @@ -655,7 +657,7 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals("offline", firstResult.exceptionOrNull()?.message) assertNull(sut.publicKey.value) - verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { publishPubkyProfile(any(), anyOrNull()) } assertTrue(sut.createIdentity("Test", "", emptyList(), emptyList(), null).isSuccess) assertEquals(VALID_SELF_KEY, sut.publicKey.value) @@ -672,9 +674,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())) .thenAnswer { throw TestAppError("offline") } - .thenReturn(mock()) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Updated"))) val firstResult = sut.createIdentity("Updated", "", emptyList(), emptyList(), null) @@ -704,12 +706,12 @@ class PubkyRepoTest : BaseUnitTest() { } Unit } - whenever(pubkyService.publishPaykitProfile(any())).doSuspendableAnswer { + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull())).doSuspendableAnswer { if (cancelDuringSignIn == false) { operationStarted.complete(Unit) awaitCancellation() } - mock() + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } for (duringSignIn in listOf(true, false)) { cancelDuringSignIn = duringSignIn @@ -756,7 +758,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()) + ).thenAnswer { throw TestAppError("Publish failed") } val result = sut.createIdentity( name = "Test", @@ -767,7 +771,7 @@ class PubkyRepoTest : BaseUnitTest() { ) assertTrue(result.isFailure) - verifyBlocking(pubkyService) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService) { publishPubkyProfile(any(), anyOrNull()) } verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } verifyBlocking(pubkyService, never()) { signIn(any()) } verifyBlocking(pubkyService, never()) { signOut() } @@ -861,9 +865,9 @@ class PubkyRepoTest : BaseUnitTest() { } emptyList() } - whenever(pubkyService.publishPaykitProfile(any())).doSuspendableAnswer { + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull())).doSuspendableAnswer { profilePublished.complete(Unit) - mock() + createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } val activeContactLoad = async { sut.loadContacts() } contactLoadStarted.await() @@ -918,7 +922,10 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(saveNewProfile().isSuccess) verifyBlocking(pubkyService) { - publishPubkyProfile(PubkyProfileUpdate(name = "New", bio = null, image = null, links = null), "revision-Old") + publishPubkyProfile( + PubkyProfileUpdate(name = "New", bio = null, image = null, links = null), + "revision-Old" + ) } assertEquals("New", sut.profile.value?.name) } @@ -1219,7 +1226,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(backupVersion + 1, sut.backupStateVersion.value) - verifyBlocking(pubkyService) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService) { publishPubkyProfile(any(), anyOrNull()) } } @Test @@ -2357,10 +2364,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) { @@ -2381,8 +2388,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) @@ -4935,7 +4942,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())).thenReturn("pubky://avatar") authenticateForTesting(publicKey = currentPublicKey, secret = session, profileName = "Alice") @@ -4943,7 +4950,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals("pubky://avatar", result.getOrNull()) - verifyBlocking(pubkyService) { uploadProfileAvatar(any(), any()) } + verifyBlocking(pubkyService) { uploadPubkyAppAvatar(any(), any()) } } @Test @@ -4957,7 +4964,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()) } } @Test @@ -5068,6 +5075,8 @@ class PubkyRepoTest : BaseUnitTest() { 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()) sut.initialize() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 0db2731906..7df461e7af 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -227,7 +227,8 @@ class PaykitSdkServiceTest { } "revision" } - val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) + val report = + PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()) whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any())).thenReturn(report) val updates = listOf(PrivatePaymentListReservationUpdateInput(RING_PUBKY, listOf(mock()))) val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } 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() From 8e0c0d906b5c6095d3a24f588526d76f8c060dd5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 17:56:23 +0100 Subject: [PATCH 03/22] fix: run pubky.app profile calls on the pubky queue --- app/src/main/java/to/bitkit/services/PubkyService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index b0968a6a72..007e9109e8 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -223,9 +223,9 @@ class PubkyService @Inject constructor( } suspend fun publishPubkyProfile(update: PubkyProfileUpdate, expectedRevision: String?): PubkyProfileSnapshot = - ServiceQueue.CORE.background { paykitSdkService.publishPubkyProfile(update, expectedRevision) } + ServiceQueue.PUBKY.background { paykitSdkService.publishPubkyProfile(update, expectedRevision) } - suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String): String = ServiceQueue.CORE.background { + suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String): String = ServiceQueue.PUBKY.background { paykitSdkService.uploadPubkyAppAvatar(bytes, contentType) } From d9b66f85af00ad4db6246a06ecc3ebeeaa9c147b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 17:57:30 +0100 Subject: [PATCH 04/22] chore: add changelog fragment --- changelog.d/next/1801.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1801.added.md 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. From 3e57c25d0b6d4c66485644f204257b025f7faedb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 18:28:51 +0100 Subject: [PATCH 05/22] fix: keep a profile deleted on pubky.app deleted --- app/src/main/java/to/bitkit/data/PubkyStore.kt | 2 ++ .../java/to/bitkit/repositories/PubkyRepo.kt | 8 ++++++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index 43e27f3409..cde5b761fb 100644 --- a/app/src/main/java/to/bitkit/data/PubkyStore.kt +++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt @@ -41,6 +41,8 @@ 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(), ) { fun cachedProfile(): PubkyCachedProfile? = cachedProfileOwner?.let { PubkyCachedProfile(publicKey = it, name = cachedName.orEmpty(), imageUri = cachedImageUri) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index cce4724813..b0747c7b02 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1187,9 +1187,13 @@ class PubkyRepo @Inject constructor( .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } .getOrNull() ?.let { PubkyProfileData.fromPaykitProfile(it.profile) } - val snapshot = readRetryingOnce(publicKey) { pubkyService.fetchPubkyProfileWithRevision(publicKey) } - ?: paykitData?.let { migrateToPubkyApp(it) } + val fetched = readRetryingOnce(publicKey) { pubkyService.fetchPubkyProfileWithRevision(publicKey) } + val hadPubkyAppProfile = publicKey in pubkyStore.data.first().pubkyAppProfileOwners + val snapshot = fetched ?: paykitData?.takeUnless { hadPubkyAppProfile }?.let { migrateToPubkyApp(it) } snapshot ?: return null + if (!hadPubkyAppProfile) { + pubkyStore.update { it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners + publicKey) } + } val profile = PubkyProfile.fromPubkyProfile(publicKey, snapshot.profile).copy(tags = paykitData?.tags.orEmpty()) if (paykitData != null && paykitData != profile.toProfileData()) publishPaykitProfileCopy(profile) return profile to snapshot.revision diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index b885af435f..3ab4120cd7 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -930,6 +930,23 @@ class PubkyRepoTest : BaseUnitTest() { 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())) + .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()) } + } + @Test fun `saveProfile reports a profile that changed in pubky app`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) From a42bc3529206ddccaca33d7e9a46eb6316a141e9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 18:44:02 +0100 Subject: [PATCH 06/22] fix: save a multi-line bio as one line --- app/src/main/java/to/bitkit/repositories/PubkyRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index b0747c7b02..a600b5eb18 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1167,7 +1167,7 @@ class PubkyRepo @Inject constructor( ): PubkyProfileSnapshot { val update = PubkyProfileUpdate( name = name.takeIf { it != base?.name }, - bio = bio.takeIf { base == null || it != base.bio }, + bio = bio.replace('\n', ' ').takeIf { base == null || it != base.bio }, image = when { base == null -> imageUrl imageUrl == base.imageUrl -> null From ff8a1e17681c11f3430c8b48422a2821a1d189e5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 20:41:03 +0100 Subject: [PATCH 07/22] fix: delete the pubky.app profile and keep a pending avatar --- .../main/java/to/bitkit/data/PubkyStore.kt | 2 + .../java/to/bitkit/repositories/PubkyRepo.kt | 112 ++++++++++++------ .../to/bitkit/services/PaykitSdkService.kt | 7 ++ .../java/to/bitkit/services/PubkyService.kt | 4 + .../to/bitkit/repositories/PubkyRepoTest.kt | 43 +++++++ 5 files changed, 135 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index cde5b761fb..4171646622 100644 --- a/app/src/main/java/to/bitkit/data/PubkyStore.kt +++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt @@ -43,6 +43,8 @@ data class PubkyStoreData( 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/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index a600b5eb18..3e235217e3 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -840,7 +840,7 @@ class PubkyRepo @Inject constructor( Logger.debug("Skipped stale profile load for '${redacted(signIn.publicKey)}'", context = TAG) return@onSuccess } - ownProfileRevision = revision + if (ownProfileRevision == null) ownProfileRevision = revision invalidateReadOnlyProfile() cacheMetadata(loadedProfile, isCurrentLoad) }.onFailure { @@ -882,8 +882,18 @@ 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 paykitData = runSuspendCatching { pubkyService.fetchPaykitProfile(prefixedKey) } + .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } + .getOrNull() + ?.let { PubkyProfileData.fromPaykitProfile(it.profile) } + readRetryingOnce(prefixedKey) { pubkyService.fetchPubkyProfileWithRevision(prefixedKey) } + ?.let { PubkyProfile.fromPubkyProfile(prefixedKey, it.profile).copy(tags = paykitData?.tags.orEmpty()) } + ?: paykitData?.toPubkyProfile(prefixedKey) + } + } /** * Resolves [publicKey]'s profile once, without retrying, for display only. A null or failed result is not proof @@ -1089,6 +1099,7 @@ class PubkyRepo @Inject constructor( ) }.getOrElse { if (!it.isConcurrentUpdate()) throw it + ownProfileRevision = null scope.launch { loadProfile() } throw PubkyProfileChangedError(it) } @@ -1117,6 +1128,7 @@ class PubkyRepo @Inject constructor( } clearSessionContactProfiles() deleteAllContacts() + if (!isRingAdoptedIdentity()) deletePubkyAppProfile() runSuspendCatching { pubkyService.deletePaykitProfile() }.getOrElse { @@ -1131,6 +1143,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) } }.getOrElse { + if (known == null || !it.isConcurrentUpdate()) throw it + val current = pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision + if (current != null) withPubkyAppScope { pubkyService.deletePubkyProfile(current) } + } + ownProfileRevision = null + } + private suspend fun deleteAllContacts() { val records = runSuspendCatching { pubkyService.contactRecords() @@ -1188,35 +1212,68 @@ class PubkyRepo @Inject constructor( .getOrNull() ?.let { PubkyProfileData.fromPaykitProfile(it.profile) } val fetched = readRetryingOnce(publicKey) { pubkyService.fetchPubkyProfileWithRevision(publicKey) } - val hadPubkyAppProfile = publicKey in pubkyStore.data.first().pubkyAppProfileOwners - val snapshot = fetched ?: paykitData?.takeUnless { hadPubkyAppProfile }?.let { migrateToPubkyApp(it) } + 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) } snapshot ?: return null + val pendingAvatar = paykitData?.image + val retryingAvatar = fetched != null && publicKey in stored.pubkyAppAvatarPending && pendingAvatar != null + if (retryingAvatar) snapshot = moveAvatar(publicKey, checkNotNull(pendingAvatar), snapshot) ?: snapshot if (!hadPubkyAppProfile) { pubkyStore.update { it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners + publicKey) } } - val profile = PubkyProfile.fromPubkyProfile(publicKey, snapshot.profile).copy(tags = paykitData?.tags.orEmpty()) - if (paykitData != null && paykitData != profile.toProfileData()) publishPaykitProfileCopy(profile) + 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(profile) + } return profile to snapshot.revision } - private suspend fun migrateToPubkyApp(data: PubkyProfileData): PubkyProfileSnapshot? = runSuspendCatching { - val update = PubkyProfileUpdate( - name = data.name.trim().take(PUBKY_APP_NAME_MAX_LENGTH), - bio = data.bio.trim().take(PUBKY_APP_BIO_MAX_LENGTH), - image = null, - links = data.links - .map { PubkyProfileDataLink(label = it.label.trim(), url = it.url.trim()) } - .filter { it.label.isNotEmpty() && it.url.isPubkyAppLinkUrl() } - .take(PUBKY_APP_LINKS_MAX_COUNT) - .map { SdkPubkyProfileLink(title = it.label.take(PUBKY_APP_LINK_TITLE_MAX_LENGTH), url = it.url) }, - ) - val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision = null) } - Logger.info("Moved Bitkit profile to pubky.app", context = TAG) - val image = data.image?.let { migrateAvatar(it) } ?: return@runSuspendCatching snapshot + private suspend fun migrateToPubkyApp(publicKey: String, data: PubkyProfileData): PubkyProfileSnapshot? = + runSuspendCatching { + val update = PubkyProfileUpdate( + name = data.name.trim().take(PUBKY_APP_NAME_MAX_LENGTH), + bio = data.bio.trim().take(PUBKY_APP_BIO_MAX_LENGTH), + image = null, + links = data.links + .map { PubkyProfileDataLink(label = it.label.trim(), url = it.url.trim()) } + .filter { it.label.isNotEmpty() && it.url.isPubkyAppLinkUrl() } + .take(PUBKY_APP_LINKS_MAX_COUNT) + .map { SdkPubkyProfileLink(title = it.label.take(PUBKY_APP_LINK_TITLE_MAX_LENGTH), url = it.url) }, + ) + val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision = null) } + Logger.info("Moved Bitkit profile to pubky.app", context = TAG) + val image = data.image ?: return@runSuspendCatching snapshot + moveAvatar(publicKey, image, snapshot) ?: 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, + ): 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" + } + val image = withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(bytes, contentType) } withPubkyAppScope { pubkyService.publishPubkyProfile(PubkyProfileUpdate(null, null, image, null), snapshot.revision) } - }.onFailure { Logger.warn("Failed to move Bitkit profile to pubky.app", it, context = TAG) }.getOrNull() + }.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 } @@ -1224,17 +1281,6 @@ class PubkyRepo @Inject constructor( private fun String.isPubkyAppLinkUrl() = length <= PUBKY_APP_LINK_URL_MAX_LENGTH && runCatching { URI(this).scheme }.getOrNull() != null - private suspend fun migrateAvatar(uri: String): String? = 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" - } - withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(bytes, contentType) } - }.onFailure { Logger.warn("Failed to move avatar to pubky.app", it, context = TAG) }.getOrNull() - private suspend fun publishPaykitProfileCopy(profile: PubkyProfile) { runSuspendCatching { pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile()) } .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9466ffcc9f..e325ac5d38 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -683,6 +683,13 @@ class PaykitSdkService @Inject constructor( } } + suspend fun deletePubkyProfile(expectedRevision: String) { + isSetup.await() + operationLock.withLock { + completeSdkCall { handle().deletePubkyProfile(expectedRevision) } + } + } + suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String): String { isSetup.await() return operationLock.withLock { diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 007e9109e8..faf2a7488f 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -225,6 +225,10 @@ class PubkyService @Inject constructor( suspend fun publishPubkyProfile(update: PubkyProfileUpdate, expectedRevision: String?): PubkyProfileSnapshot = ServiceQueue.PUBKY.background { paykitSdkService.publishPubkyProfile(update, expectedRevision) } + suspend fun deletePubkyProfile(expectedRevision: String) = ServiceQueue.PUBKY.background { + paykitSdkService.deletePubkyProfile(expectedRevision) + } + suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String): String = ServiceQueue.PUBKY.background { paykitSdkService.uploadPubkyAppAvatar(bytes, contentType) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 3ab4120cd7..068ec32e93 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -947,6 +947,49 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, times(2)) { publishPubkyProfile(any(), anyOrNull()) } } + @Test + fun `saveProfile checks the revision the edit started from after a refresh`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY, profileName = "Old") + whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) + .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Newer"))) + sut.loadProfile() + + saveNewProfile() + + verifyBlocking(pubkyService) { publishPubkyProfile(any(), eq("revision-Old")) } + } + + @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()) } + } + + @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") } + } + @Test fun `saveProfile reports a profile that changed in pubky app`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) From 2f4f1354d0ee245117d9bdf145acfeee2ecf8514 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 20:54:30 +0100 Subject: [PATCH 08/22] fix: move every bitkit profile to pubky.app and keep tags safe --- .../java/to/bitkit/repositories/PubkyRepo.kt | 79 ++++++++++++++----- .../to/bitkit/repositories/PubkyRepoTest.kt | 63 +++++++++++++++ 2 files changed, 124 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 3e235217e3..ce1f51928a 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -68,7 +68,6 @@ import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyModerationBot import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData -import to.bitkit.models.PubkyProfileDataLink import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkySessionBackupKind @@ -206,6 +205,10 @@ class PubkyRepo @Inject constructor( /** 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 private val signInGeneration = AtomicLong(0L) private val storedIdentityRevision = AtomicLong(0L) private val readOnlyProfileGeneration = AtomicLong(0L) @@ -1094,7 +1097,7 @@ class PubkyRepo @Inject constructor( links, tags, imageUrl, - base = _profile.value, + base = _profile.value.takeIf { ownProfileRevision != null }, expectedRevision = ownProfileRevision ) }.getOrElse { @@ -1189,9 +1192,16 @@ class PubkyRepo @Inject constructor( base: PubkyProfile?, expectedRevision: String?, ): PubkyProfileSnapshot { + val links = links.map { + PubkyProfileLink( + label = it.label.singleLine(), + url = it.url.toPubkyAppLinkUrl() ?: it.url + ) + } + val bio = bio.singleLine() val update = PubkyProfileUpdate( - name = name.takeIf { it != base?.name }, - bio = bio.replace('\n', ' ').takeIf { base == null || it != base.bio }, + name = name.singleLine().takeIf { it != base?.name }, + bio = bio.takeIf { base == null || it != base.bio }, image = when { base == null -> imageUrl imageUrl == base.imageUrl -> null @@ -1202,21 +1212,37 @@ class PubkyRepo @Inject constructor( ) val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision) } ownProfileRevision = snapshot.revision - publishPaykitProfileCopy(PubkyProfile.fromPubkyProfile("", snapshot.profile).copy(tags = tags)) + val keptTags = tagsToKeep(tags) + val copy = PubkyProfile.fromPubkyProfile("", snapshot.profile).copy(tags = keptTags) + // Tags live only in the Paykit copy, so a changed tag that it does not take is reported. + runSuspendCatching { pubkyService.publishPaykitProfile(copy.toProfileData().toPaykitProfile()) }.onFailure { + Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) + if (keptTags != base?.tags.orEmpty()) throw it + } return snapshot } - private suspend fun loadOwnProfile(publicKey: String): Pair? { - val paykitData = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) } + private suspend fun tagsToKeep(tags: List): List { + val publicKey = _publicKey.value ?: return tags + if (!tagsUnavailable) return tags + val stored = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) }.getOrNull() ?: return tags + tagsUnavailable = false + val storedTags = PubkyProfileData.fromPaykitProfile(stored.profile).tags + return storedTags + tags.filterNot { it in storedTags } + } + + private suspend fun loadOwnProfile(publicKey: String): Pair? { + val paykitRead = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) } .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } - .getOrNull() - ?.let { PubkyProfileData.fromPaykitProfile(it.profile) } + tagsUnavailable = paykitRead.isFailure + val paykitData = paykitRead.getOrNull()?.let { PubkyProfileData.fromPaykitProfile(it.profile) } val fetched = readRetryingOnce(publicKey) { pubkyService.fetchPubkyProfileWithRevision(publicKey) } 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) } - snapshot ?: return null + // 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 val retryingAvatar = fetched != null && publicKey in stored.pubkyAppAvatarPending && pendingAvatar != null if (retryingAvatar) snapshot = moveAvatar(publicKey, checkNotNull(pendingAvatar), snapshot) ?: snapshot @@ -1237,14 +1263,16 @@ class PubkyRepo @Inject constructor( private suspend fun migrateToPubkyApp(publicKey: String, data: PubkyProfileData): PubkyProfileSnapshot? = runSuspendCatching { val update = PubkyProfileUpdate( - name = data.name.trim().take(PUBKY_APP_NAME_MAX_LENGTH), - bio = data.bio.trim().take(PUBKY_APP_BIO_MAX_LENGTH), + name = data.name.singleLine().takeCodePoints(PUBKY_APP_NAME_MAX_LENGTH), + bio = data.bio.singleLine().takeCodePoints(PUBKY_APP_BIO_MAX_LENGTH), image = null, links = data.links - .map { PubkyProfileDataLink(label = it.label.trim(), url = it.url.trim()) } - .filter { it.label.isNotEmpty() && it.url.isPubkyAppLinkUrl() } - .take(PUBKY_APP_LINKS_MAX_COUNT) - .map { SdkPubkyProfileLink(title = it.label.take(PUBKY_APP_LINK_TITLE_MAX_LENGTH), url = it.url) }, + .mapNotNull { link -> + val title = link.label.singleLine().takeIf { it.isNotEmpty() } ?: return@mapNotNull null + val url = link.url.toPubkyAppLinkUrl() ?: return@mapNotNull null + SdkPubkyProfileLink(title = title.takeCodePoints(PUBKY_APP_LINK_TITLE_MAX_LENGTH), url = url) + } + .take(PUBKY_APP_LINKS_MAX_COUNT), ) val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision = null) } Logger.info("Moved Bitkit profile to pubky.app", context = TAG) @@ -1278,8 +1306,23 @@ class PubkyRepo @Inject constructor( private fun Throwable.isConcurrentUpdate() = generateSequence(this) { it.cause }.any { it is PaykitException.ConcurrentUpdate } - private fun String.isPubkyAppLinkUrl() = - length <= PUBKY_APP_LINK_URL_MAX_LENGTH && runCatching { URI(this).scheme }.getOrNull() != null + private fun String.singleLine() = map { if (it.isISOControl()) ' ' else it }.joinToString("").trim() + + private fun String.takeCodePoints(max: Int) = + if (codePointCount(0, length) <= max) this else substring(0, offsetByCodePoints(0, max)) + + private fun String.toPubkyAppLinkUrl(): String? { + val value = trim().takeIf { it.isNotEmpty() } ?: return null + val url = when { + runCatching { URI(value).scheme }.getOrNull() != null -> value + '@' in value -> "mailto:$value" + value.all { it in "+0123456789 -()" } -> "tel:" + value.filterNot { it in " -()" } + else -> "https://$value" + } + val valid = url.codePointCount(0, url.length) <= PUBKY_APP_LINK_URL_MAX_LENGTH && + runCatching { URI(url).scheme }.getOrNull() != null + return url.takeIf { valid } + } private suspend fun publishPaykitProfileCopy(profile: PubkyProfile) { runSuspendCatching { pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile()) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 068ec32e93..a389141d7b 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -83,6 +83,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 @@ -105,6 +107,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() { @@ -990,6 +993,66 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { deletePubkyProfile("revision-Test") } } + @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") + ), + ).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"), + ), + ), + null, + ) + } + } + + @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())) + .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) From b17caec6bdff86a685605fd02e7e7057b6882645 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 21:09:24 +0100 Subject: [PATCH 09/22] fix: bind profile writes to their identity and edit --- .../main/java/to/bitkit/data/PubkyStore.kt | 8 ++- .../java/to/bitkit/repositories/PubkyRepo.kt | 70 ++++++++++++------- .../to/bitkit/services/PaykitSdkService.kt | 25 +++++-- .../java/to/bitkit/services/PubkyService.kt | 22 +++--- .../screens/profile/EditProfileViewModel.kt | 7 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 59 ++++++++-------- .../profile/EditProfileViewModelTest.kt | 13 ++-- 7 files changed, 132 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index 4171646622..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, + ) + } } } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index ce1f51928a..4025f26fba 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -115,6 +115,9 @@ 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. */ +data class ProfileEditBase(val profile: PubkyProfile?, val revision: String?) + /** 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) @@ -934,7 +937,7 @@ 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) + val imageUrl = publishIdentityProfile(publicKey, name, bio, links, tags, avatarBytes) finishIdentityCreation(publicKey, name, bio, links, tags, imageUrl) } } @@ -948,7 +951,7 @@ class PubkyRepo @Inject constructor( val publicKeyZ32 = _publicKey.value ?: createLocalIdentitySession { shouldRevokeSessionOnFailure = true } - val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) + val imageUrl = publishIdentityProfile(publicKeyZ32, name, bio, links, tags, avatarBytes) shouldRevokeSessionOnFailure = false finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl) } @@ -994,22 +997,20 @@ 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() } + val imageUrl = avatarBytes?.let { uploadAvatar(it, publicKey).getOrNull() } runSuspendCatching { - writeProfile(name, bio, links, tags, imageUrl, base = null, expectedRevision = null) + writeProfile(publicKey, name, bio, links, tags, imageUrl, base = null, expectedRevision = null) }.getOrElse { if (!it.isConcurrentUpdate()) throw it - val publicKey = requireNotNull( - pubkyService.currentPublicKey() - ) { "No active Pubky session" }.ensurePubkyPrefix() val existingRevision = pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision - writeProfile(name, bio, links, tags, imageUrl, base = null, expectedRevision = existingRevision) + writeProfile(publicKey, name, bio, links, tags, imageUrl, base = null, expectedRevision = existingRevision) } return imageUrl } @@ -1068,22 +1069,35 @@ 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) - withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(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) + + 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)) { @@ -1092,13 +1106,14 @@ class PubkyRepo @Inject constructor( val pk = requireNotNull(_publicKey.value) { "No public key available" } val snapshot = runSuspendCatching { writeProfile( + pk, name, bio, links, tags, imageUrl, - base = _profile.value.takeIf { ownProfileRevision != null }, - expectedRevision = ownProfileRevision + base = editBase.profile.takeIf { editBase.revision != null }, + expectedRevision = editBase.revision ) }.getOrElse { if (!it.isConcurrentUpdate()) throw it @@ -1150,10 +1165,10 @@ class PubkyRepo @Inject constructor( val publicKey = _publicKey.value ?: return val known = ownProfileRevision val revision = known ?: pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision ?: return - runSuspendCatching { withPubkyAppScope { pubkyService.deletePubkyProfile(revision) } }.getOrElse { + 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) } + if (current != null) withPubkyAppScope { pubkyService.deletePubkyProfile(current, publicKey) } } ownProfileRevision = null } @@ -1184,6 +1199,7 @@ class PubkyRepo @Inject constructor( } private suspend fun writeProfile( + publicKey: String, name: String, bio: String, links: List, @@ -1210,20 +1226,24 @@ class PubkyRepo @Inject constructor( links = links.takeIf { base == null || it != base.links } ?.map { SdkPubkyProfileLink(title = it.label, url = it.url) }, ) - val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision) } + val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision, publicKey) } ownProfileRevision = snapshot.revision - val keptTags = tagsToKeep(tags) + val keptTags = tagsToKeep(publicKey, tags) val copy = PubkyProfile.fromPubkyProfile("", snapshot.profile).copy(tags = keptTags) // Tags live only in the Paykit copy, so a changed tag that it does not take is reported. - runSuspendCatching { pubkyService.publishPaykitProfile(copy.toProfileData().toPaykitProfile()) }.onFailure { + runSuspendCatching { + pubkyService.publishPaykitProfile( + copy.toProfileData().toPaykitProfile(), + publicKey + ) + }.onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) if (keptTags != base?.tags.orEmpty()) throw it } return snapshot } - private suspend fun tagsToKeep(tags: List): List { - val publicKey = _publicKey.value ?: return tags + private suspend fun tagsToKeep(publicKey: String, tags: List): List { if (!tagsUnavailable) return tags val stored = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) }.getOrNull() ?: return tags tagsUnavailable = false @@ -1255,7 +1275,7 @@ class PubkyRepo @Inject constructor( it.copy(tags = paykitData?.tags.orEmpty(), imageUrl = if (avatarPending) pendingAvatar else it.imageUrl) } if (!avatarPending && paykitData != null && paykitData != profile.toProfileData()) { - publishPaykitProfileCopy(profile) + publishPaykitProfileCopy(publicKey, profile) } return profile to snapshot.revision } @@ -1274,7 +1294,7 @@ class PubkyRepo @Inject constructor( } .take(PUBKY_APP_LINKS_MAX_COUNT), ) - val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision = null) } + val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, null, publicKey) } Logger.info("Moved Bitkit profile to pubky.app", context = TAG) val image = data.image ?: return@runSuspendCatching snapshot moveAvatar(publicKey, image, snapshot) ?: snapshot @@ -1292,9 +1312,9 @@ class PubkyRepo @Inject constructor( "gif" -> "image/gif" else -> "image/jpeg" } - val image = withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(bytes, contentType) } + val image = withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(bytes, contentType, publicKey) } withPubkyAppScope { - pubkyService.publishPubkyProfile(PubkyProfileUpdate(null, null, image, null), snapshot.revision) + pubkyService.publishPubkyProfile(PubkyProfileUpdate(null, null, image, null), snapshot.revision, publicKey) } }.onSuccess { pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - publicKey) } @@ -1324,8 +1344,8 @@ class PubkyRepo @Inject constructor( return url.takeIf { valid } } - private suspend fun publishPaykitProfileCopy(profile: PubkyProfile) { - runSuspendCatching { pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile()) } + private suspend fun publishPaykitProfileCopy(publicKey: String, profile: PubkyProfile) { + runSuspendCatching { pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile(), publicKey) } .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index e325ac5d38..e6c4866f5c 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -624,11 +624,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() } @@ -676,27 +677,40 @@ class PaykitSdkService @Inject constructor( suspend fun fetchPaykitProfile(publicKey: String): PaykitProfileRecord? = publicRead { it.fetchPaykitProfile(publicKey) } - suspend fun publishPubkyProfile(update: PubkyProfileUpdate, expectedRevision: String?): PubkyProfileSnapshot { + 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) { + 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): String { + 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) } @@ -2286,3 +2300,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 faf2a7488f..1b5264c76e 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -202,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 { @@ -222,17 +222,21 @@ class PubkyService @Inject constructor( paykitSdkService.fetchPaykitProfile(publicKey) } - suspend fun publishPubkyProfile(update: PubkyProfileUpdate, expectedRevision: String?): PubkyProfileSnapshot = - ServiceQueue.PUBKY.background { paykitSdkService.publishPubkyProfile(update, expectedRevision) } - - suspend fun deletePubkyProfile(expectedRevision: String) = ServiceQueue.PUBKY.background { - paykitSdkService.deletePubkyProfile(expectedRevision) + suspend fun publishPubkyProfile( + update: PubkyProfileUpdate, + expectedRevision: String?, + expectedIdentity: String, + ): PubkyProfileSnapshot = ServiceQueue.PUBKY.background { + paykitSdkService.publishPubkyProfile(update, expectedRevision, expectedIdentity) } - suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String): String = ServiceQueue.PUBKY.background { - paykitSdkService.uploadPubkyAppAvatar(bytes, contentType) + 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/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index 71a1c24ecf..2d58ce8361 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 @@ -26,6 +26,7 @@ import to.bitkit.models.PROFILE_NAME_MAX_LENGTH import to.bitkit.models.PubkyProfileLink import to.bitkit.models.Toast import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.ProfileEditBase import to.bitkit.repositories.PubkyProfileChangedError import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink @@ -43,6 +44,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() @@ -56,7 +59,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) { @@ -206,6 +210,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( diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index a389141d7b..ca97a7eec7 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -163,7 +163,7 @@ class PubkyRepoTest : BaseUnitTest() { } whenever(pubkyService.contactRecords()).thenReturn(emptyList()) whenever(pubkyService.currentPublicKey()).thenReturn(VALID_SELF_KEY) - whenever { pubkyService.publishPubkyProfile(any(), anyOrNull()) }.doSuspendableAnswer { + whenever { pubkyService.publishPubkyProfile(any(), anyOrNull(), any()) }.doSuspendableAnswer { val update = it.getArgument(0) createSnapshot( VALID_SELF_KEY, @@ -579,7 +579,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.publicKeyFromSecret("test-secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) whenever(pubkyService.signUp("test-secret", "test-homeserver", "test-code")).thenReturn(Unit) whenever( - pubkyService.publishPubkyProfile(any(), anyOrNull()) + pubkyService.publishPubkyProfile(any(), anyOrNull(), any()) ).thenAnswer { throw TestAppError("Publish failed") } whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } @@ -609,7 +609,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertFalse(profileSetupPending.value) verify(keychain).loadString(Keychain.Key.BIP39_MNEMONIC.name) - verifyBlocking(pubkyService, never()) { publishPubkyProfile(any(), anyOrNull()) } + verifyBlocking(pubkyService, never()) { publishPubkyProfile(any(), anyOrNull(), any()) } } @Test @@ -619,7 +619,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) @@ -646,7 +646,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()) { publishPubkyProfile(any(), anyOrNull()) } + verifyBlocking(pubkyService, never()) { publishPubkyProfile(any(), anyOrNull(), any()) } } @Test @@ -654,13 +654,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()) { publishPubkyProfile(any(), anyOrNull()) } + verifyBlocking(pubkyService, never()) { publishPubkyProfile(any(), anyOrNull(), any()) } assertTrue(sut.createIdentity("Test", "", emptyList(), emptyList(), null).isSuccess) assertEquals(VALID_SELF_KEY, sut.publicKey.value) @@ -677,7 +677,7 @@ 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.publishPubkyProfile(any(), anyOrNull())) + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())) .thenAnswer { throw TestAppError("offline") } .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile(name = "Updated"))) @@ -709,7 +709,7 @@ class PubkyRepoTest : BaseUnitTest() { } Unit } - whenever(pubkyService.publishPubkyProfile(any(), anyOrNull())).doSuspendableAnswer { + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())).doSuspendableAnswer { if (cancelDuringSignIn == false) { operationStarted.complete(Unit) awaitCancellation() @@ -743,7 +743,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) @@ -762,7 +762,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.approveSignupAuth(ringSignupRequest()).isSuccess) clearInvocations(pubkyService) whenever( - pubkyService.publishPubkyProfile(any(), anyOrNull()) + pubkyService.publishPubkyProfile(any(), anyOrNull(), any()) ).thenAnswer { throw TestAppError("Publish failed") } val result = sut.createIdentity( @@ -774,7 +774,7 @@ class PubkyRepoTest : BaseUnitTest() { ) assertTrue(result.isFailure) - verifyBlocking(pubkyService) { publishPubkyProfile(any(), anyOrNull()) } + verifyBlocking(pubkyService) { publishPubkyProfile(any(), anyOrNull(), any()) } verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } verifyBlocking(pubkyService, never()) { signIn(any()) } verifyBlocking(pubkyService, never()) { signOut() } @@ -791,7 +791,7 @@ 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.publishPaykitProfile(any(), any())).thenReturn(mock()) whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) .thenReturn(createSnapshot(VALID_SELF_KEY, createPubkyProfile())) whenever(pubkyService.contactRecords()).doSuspendableAnswer { @@ -828,7 +828,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") } @@ -842,7 +842,7 @@ 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.publishPaykitProfile(any(), any())).thenReturn(mock()) whenever(pubkyService.fetchPubkyProfileWithRevision(VALID_SELF_KEY)) .thenReturn(null) clearInvocations(pubkyService) @@ -868,7 +868,7 @@ class PubkyRepoTest : BaseUnitTest() { } emptyList() } - whenever(pubkyService.publishPubkyProfile(any(), anyOrNull())).doSuspendableAnswer { + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())).doSuspendableAnswer { profilePublished.complete(Unit) createSnapshot(VALID_SELF_KEY, createPubkyProfile()) } @@ -927,7 +927,8 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { publishPubkyProfile( PubkyProfileUpdate(name = "New", bio = null, image = null, links = null), - "revision-Old" + "revision-Old", + VALID_SELF_KEY, ) } assertEquals("New", sut.profile.value?.name) @@ -940,14 +941,14 @@ 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(VALID_SELF_KEY.removePrefix("pubky")) - whenever(pubkyService.publishPubkyProfile(any(), anyOrNull())) + 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()) } + verifyBlocking(pubkyService, times(2)) { publishPubkyProfile(any(), anyOrNull(), any()) } } @Test @@ -959,7 +960,7 @@ class PubkyRepoTest : BaseUnitTest() { saveNewProfile() - verifyBlocking(pubkyService) { publishPubkyProfile(any(), eq("revision-Old")) } + verifyBlocking(pubkyService) { publishPubkyProfile(any(), eq("revision-Old"), any()) } } @Test @@ -981,7 +982,7 @@ class PubkyRepoTest : BaseUnitTest() { sut.loadProfile() assertEquals("pubky://self/pub/paykit/blobs/avatar.jpg", sut.profile.value?.imageUrl) - verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any(), any()) } } @Test @@ -990,7 +991,7 @@ class PubkyRepoTest : BaseUnitTest() { sut.deleteProfile() - verifyBlocking(pubkyService) { deletePubkyProfile("revision-Test") } + verifyBlocking(pubkyService) { deletePubkyProfile("revision-Test", VALID_SELF_KEY) } } @Test @@ -1028,6 +1029,7 @@ class PubkyRepoTest : BaseUnitTest() { ), ), null, + VALID_SELF_KEY, ) } } @@ -1045,7 +1047,7 @@ class PubkyRepoTest : BaseUnitTest() { updatedAt = "2026-01-01T00:00:00Z", ) ) - whenever(pubkyService.publishPubkyProfile(any(), anyOrNull())) + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())) .thenAnswer { throw PaykitException.Protocol("protocol", "Pubky profile name must be 3 to 50 characters") } sut.loadProfile() @@ -1056,7 +1058,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `saveProfile reports a profile that changed in pubky app`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) - whenever(pubkyService.publishPubkyProfile(any(), anyOrNull())) + whenever(pubkyService.publishPubkyProfile(any(), anyOrNull(), any())) .thenAnswer { throw PaykitException.ConcurrentUpdate("concurrent_update", "Pubky profile changed") } val result = saveNewProfile() @@ -1084,6 +1086,7 @@ class PubkyRepoTest : BaseUnitTest() { publishPubkyProfile( PubkyProfileUpdate(name = "Bitkit Name", bio = "b".repeat(160), image = null, links = emptyList()), null, + VALID_SELF_KEY, ) } assertEquals("Bitkit Name", sut.profile.value?.name) @@ -1349,7 +1352,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(backupVersion + 1, sut.backupStateVersion.value) - verifyBlocking(pubkyService) { publishPubkyProfile(any(), anyOrNull()) } + verifyBlocking(pubkyService) { publishPubkyProfile(any(), anyOrNull(), any()) } } @Test @@ -5065,7 +5068,7 @@ class PubkyRepoTest : BaseUnitTest() { val session = "test_session" val currentPublicKey = "pubkyalice" whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) - whenever(pubkyService.uploadPubkyAppAvatar(any(), any())).thenReturn("pubky://avatar") + whenever(pubkyService.uploadPubkyAppAvatar(any(), any(), any())).thenReturn("pubky://avatar") authenticateForTesting(publicKey = currentPublicKey, secret = session, profileName = "Alice") @@ -5073,7 +5076,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals("pubky://avatar", result.getOrNull()) - verifyBlocking(pubkyService) { uploadPubkyAppAvatar(any(), any()) } + verifyBlocking(pubkyService) { uploadPubkyAppAvatar(any(), any(), any()) } } @Test @@ -5087,7 +5090,7 @@ class PubkyRepoTest : BaseUnitTest() { val result = sut.uploadAvatar(byteArrayOf(1, 2, 3)) assertTrue(result.isFailure) - verifyBlocking(pubkyService, never()) { uploadPubkyAppAvatar(any(), any()) } + verifyBlocking(pubkyService, never()) { uploadPubkyAppAvatar(any(), any(), any()) } } @Test 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)) From 077b8b0a633f1a7e6468e2f5548314e5f388c95b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 21:20:17 +0100 Subject: [PATCH 10/22] fix: keep the repository's profile revision current on refresh --- .../main/java/to/bitkit/repositories/PubkyRepo.kt | 2 +- .../java/to/bitkit/repositories/PubkyRepoTest.kt | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 4025f26fba..6e1fa22f13 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -846,7 +846,7 @@ class PubkyRepo @Inject constructor( Logger.debug("Skipped stale profile load for '${redacted(signIn.publicKey)}'", context = TAG) return@onSuccess } - if (ownProfileRevision == null) ownProfileRevision = revision + ownProfileRevision = revision invalidateReadOnlyProfile() cacheMetadata(loadedProfile, isCurrentLoad) }.onFailure { diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index ca97a7eec7..146f49dde5 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -952,13 +952,22 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `saveProfile checks the revision the edit started from after a refresh`() = 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) - saveNewProfile() + sut.saveProfile( + name = "New", + bio = "", + links = emptyList(), + tags = emptyList(), + imageUrl = null, + editBase = editBase + ) verifyBlocking(pubkyService) { publishPubkyProfile(any(), eq("revision-Old"), any()) } } From 046998cea92213d794c4ed6db11a3ef2c6d113ad Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 22:00:21 +0100 Subject: [PATCH 11/22] fix: keep pubky.app edits on restore, avatar moves and tag saves --- .../java/to/bitkit/models/PubkyProfile.kt | 8 +- .../java/to/bitkit/repositories/PubkyRepo.kt | 176 ++++++++++++------ .../bitkit/ui/components/ProfileEditForm.kt | 44 +++-- .../screens/profile/CreateProfileViewModel.kt | 8 +- .../ui/screens/profile/EditProfileScreen.kt | 2 + .../screens/profile/EditProfileViewModel.kt | 10 +- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../to/bitkit/repositories/PubkyRepoTest.kt | 81 +++++++- 23 files changed, 257 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyProfile.kt b/app/src/main/java/to/bitkit/models/PubkyProfile.kt index f8affc604a..214f48a309 100644 --- a/app/src/main/java/to/bitkit/models/PubkyProfile.kt +++ b/app/src/main/java/to/bitkit/models/PubkyProfile.kt @@ -13,8 +13,14 @@ 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 -fun isValidProfileName(name: String) = name.trim().length in PROFILE_NAME_MIN_LENGTH..PROFILE_NAME_MAX_LENGTH +/** 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 6e1fa22f13..594acfbf41 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -72,6 +72,7 @@ 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 @@ -121,6 +122,14 @@ data class ProfileEditBase(val profile: PubkyProfile?, val revision: String?) /** 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) +class PubkyProfileTagsUnavailableError : AppError("Profile tags could not be read") + +private class PubkyProfileNotFoundError : AppError("Profile not found") + +private class SavedProfile(val profile: PubkyProfile, val revision: String, val tagError: Throwable?) + +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. */ @@ -176,6 +185,7 @@ class PubkyRepo @Inject constructor( 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 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 @@ -212,6 +222,9 @@ class PubkyRepo @Inject constructor( /** 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) @@ -834,7 +847,7 @@ class PubkyRepo @Inject constructor( try { runSuspendCatching { withContext(ioDispatcher) { - loadOwnProfile(signIn.publicKey) ?: throw AppError("Profile not found") + loadOwnProfile(signIn.publicKey) ?: throw PubkyProfileNotFoundError() } }.onSuccess { (loadedProfile, revision) -> var isCurrent = false @@ -851,6 +864,12 @@ class PubkyRepo @Inject constructor( 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 @@ -891,13 +910,21 @@ class PubkyRepo @Inject constructor( suspend fun fetchRemoteProfile(publicKey: String): Result = runSuspendCatching { withContext(ioDispatcher) { val prefixedKey = publicKey.ensurePubkyPrefix() - val paykitData = runSuspendCatching { pubkyService.fetchPaykitProfile(prefixedKey) } + val paykitRead = runSuspendCatching { pubkyService.fetchPaykitProfile(prefixedKey) } .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } - .getOrNull() - ?.let { PubkyProfileData.fromPaykitProfile(it.profile) } - readRetryingOnce(prefixedKey) { pubkyService.fetchPubkyProfileWithRevision(prefixedKey) } - ?.let { PubkyProfile.fromPubkyProfile(prefixedKey, it.profile).copy(tags = paykitData?.tags.orEmpty()) } - ?: paykitData?.toPubkyProfile(prefixedKey) + val paykitData = paykitRead.getOrNull()?.let { PubkyProfileData.fromPaykitProfile(it.profile) } + val snapshot = readRetryingOnce(prefixedKey) { pubkyService.fetchPubkyProfileWithRevision(prefixedKey) } + restoreBase = null + 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) + } } } @@ -937,8 +964,8 @@ class PubkyRepo @Inject constructor( return@withLock runSuspendCatching { withContext(ioDispatcher) { val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" } - val imageUrl = publishIdentityProfile(publicKey, name, bio, links, tags, avatarBytes) - finishIdentityCreation(publicKey, name, bio, links, tags, imageUrl) + val saved = publishIdentityProfile(publicKey, name, bio, links, tags, avatarBytes) + finishIdentityCreation(publicKey, saved) } } } @@ -951,9 +978,9 @@ class PubkyRepo @Inject constructor( val publicKeyZ32 = _publicKey.value ?: createLocalIdentitySession { shouldRevokeSessionOnFailure = true } - val imageUrl = publishIdentityProfile(publicKeyZ32, 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) @@ -1003,36 +1030,26 @@ class PubkyRepo @Inject constructor( links: List, tags: List, avatarBytes: ByteArray?, - ): String? { - val imageUrl = avatarBytes?.let { uploadAvatar(it, publicKey).getOrNull() } - runSuspendCatching { - writeProfile(publicKey, name, bio, links, tags, imageUrl, base = null, expectedRevision = null) + ): 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 - val existingRevision = pubkyService.fetchPubkyProfileWithRevision(publicKey)?.revision - writeProfile(publicKey, name, bio, links, tags, imageUrl, base = null, expectedRevision = existingRevision) + throw PubkyProfileChangedError(it) } - return imageUrl + 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) @@ -1104,7 +1121,7 @@ class PubkyRepo @Inject constructor( "No session available" } val pk = requireNotNull(_publicKey.value) { "No public key available" } - val snapshot = runSuspendCatching { + val saved = runSuspendCatching { writeProfile( pk, name, @@ -1121,11 +1138,11 @@ class PubkyRepo @Inject constructor( scope.launch { loadProfile() } throw PubkyProfileChangedError(it) } - val profile = PubkyProfile.fromPubkyProfile(pk, snapshot.profile).copy(tags = tags) - setProfile(profile) - ownProfileRevision = snapshot.revision - cacheMetadata(profile) + setProfile(saved.profile) + ownProfileRevision = saved.revision + cacheMetadata(saved.profile) notifyBackupStateChanged() + saved.tagError?.let { throw it } } } @@ -1207,7 +1224,7 @@ class PubkyRepo @Inject constructor( imageUrl: String?, base: PubkyProfile?, expectedRevision: String?, - ): PubkyProfileSnapshot { + ): SavedProfile { val links = links.map { PubkyProfileLink( label = it.label.singleLine(), @@ -1215,10 +1232,13 @@ class PubkyRepo @Inject constructor( ) } val bio = bio.singleLine() + // 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() @@ -1228,26 +1248,39 @@ class PubkyRepo @Inject constructor( ) val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision, publicKey) } ownProfileRevision = snapshot.revision + val avatarPending = paykitAvatar != null && snapshot.profile.image == null + pubkyStore.update { + val keys = it.pubkyAppAvatarPending + val pending = if (avatarPending) keys + publicKey else keys - publicKey + it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners + publicKey, pubkyAppAvatarPending = pending) + } + val baseTags = base?.tags.orEmpty() val keptTags = tagsToKeep(publicKey, tags) - val copy = PubkyProfile.fromPubkyProfile("", snapshot.profile).copy(tags = keptTags) + val profile = PubkyProfile.fromPubkyProfile(publicKey, snapshot.profile).let { + it.copy(tags = keptTags ?: baseTags, imageUrl = if (avatarPending) paykitAvatar else it.imageUrl) + } // Tags live only in the Paykit copy, so a changed tag that it does not take is reported. - runSuspendCatching { - pubkyService.publishPaykitProfile( - copy.toProfileData().toPaykitProfile(), - publicKey - ) - }.onFailure { - Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) - if (keptTags != base?.tags.orEmpty()) throw it + val copyData = profile.toProfileData().toPaykitProfile() + val copyError = keptTags?.let { + runSuspendCatching { pubkyService.publishPaykitProfile(copyData, publicKey) } + .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } + .exceptionOrNull() } - return snapshot + val tagError = when { + keptTags == null && tags != baseTags -> PubkyProfileTagsUnavailableError() + copyError != null && keptTags != baseTags -> copyError + else -> null + } + val stored = if (keptTags != null && copyError == null) profile else profile.copy(tags = baseTags) + return SavedProfile(stored, snapshot.revision, tagError) } - private suspend fun tagsToKeep(publicKey: String, tags: List): List { + /** The stored tags plus any new ones; null while the Paykit copy still cannot be read. */ + private suspend fun tagsToKeep(publicKey: String, tags: List): List? { if (!tagsUnavailable) return tags - val stored = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) }.getOrNull() ?: return tags + val stored = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) }.getOrElse { return null } tagsUnavailable = false - val storedTags = PubkyProfileData.fromPaykitProfile(stored.profile).tags + val storedTags = stored?.let { PubkyProfileData.fromPaykitProfile(it.profile).tags }.orEmpty() return storedTags + tags.filterNot { it in storedTags } } @@ -1257,6 +1290,7 @@ class PubkyRepo @Inject constructor( 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 @@ -1264,7 +1298,12 @@ class PubkyRepo @Inject constructor( // 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 - val retryingAvatar = fetched != null && publicKey in stored.pubkyAppAvatarPending && pendingAvatar != null + var retryingAvatar = fetched != null && publicKey in stored.pubkyAppAvatarPending && pendingAvatar != null + if (retryingAvatar && fetched?.profile?.image != null) { + // An avatar set on pubky.app meanwhile wins over the one still to move. + pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - publicKey) } + retryingAvatar = false + } if (retryingAvatar) snapshot = moveAvatar(publicKey, checkNotNull(pendingAvatar), snapshot) ?: snapshot if (!hadPubkyAppProfile) { pubkyStore.update { it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners + publicKey) } @@ -1328,22 +1367,35 @@ class PubkyRepo @Inject constructor( private fun String.singleLine() = map { if (it.isISOControl()) ' ' else it }.joinToString("").trim() - private fun String.takeCodePoints(max: Int) = - if (codePointCount(0, length) <= max) this else substring(0, offsetByCodePoints(0, max)) - 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 { - runCatching { URI(value).scheme }.getOrNull() != null -> value - '@' in value -> "mailto:$value" - value.all { it in "+0123456789 -()" } -> "tel:" + value.filterNot { it in " -()" } - else -> "https://$value" + !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 valid = url.codePointCount(0, url.length) <= PUBKY_APP_LINK_URL_MAX_LENGTH && runCatching { URI(url).scheme }.getOrNull() != null return url.takeIf { valid } } + 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) + } + + private fun String.isPaykitStorage() = "/pub/paykit/" in this + private suspend fun publishPaykitProfileCopy(publicKey: String, profile: PubkyProfile) { runSuspendCatching { pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile(), publicKey) } .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } 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/CreateProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/CreateProfileViewModel.kt index 21936d472f..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 @@ -20,6 +20,8 @@ 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 @@ -126,7 +128,7 @@ class CreateProfileViewModel @Inject constructor( } fun onNameChange(name: String) { - _uiState.update { it.copy(name = name.take(PROFILE_NAME_MAX_LENGTH)) } + _uiState.update { it.copy(name = name.takeCodePoints(PROFILE_NAME_MAX_LENGTH)) } } fun onBioChange(bio: String) { @@ -199,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 813de2bcf3..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,7 @@ 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 @@ -171,6 +172,7 @@ private fun Content( 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 2d58ce8361..13df38706d 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 @@ -25,9 +25,11 @@ 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.PubkyProfileTagsUnavailableError import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus @@ -111,7 +113,7 @@ class EditProfileViewModel @Inject constructor( } fun onNameChange(name: String) { - _uiState.update { it.copy(name = name.take(PROFILE_NAME_MAX_LENGTH)) } + _uiState.update { it.copy(name = name.takeCodePoints(PROFILE_NAME_MAX_LENGTH)) } } fun onBioChange(bio: String) { @@ -226,7 +228,11 @@ class EditProfileViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.profile__edit_save_error), - description = if (changed) context.getString(R.string.profile__edit_changed_error) else it.message, + description = when { + changed -> context.getString(R.string.profile__edit_changed_error) + it is PubkyProfileTagsUnavailableError -> context.getString(R.string.profile__edit_tags_error) + else -> it.message + }, ) if (changed) reloadAfterExternalEdit() } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index dc8c902d66..649c5874f7 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1317,6 +1317,7 @@ فشل حفظ الملف الشخصي تم حفظ الملف الشخصي العلامات + تعذّر على 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 b7fb2b2cab..a3b465720e 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1342,6 +1342,7 @@ No se pudo guardar el perfil Perfil guardado ETIQUETAS + Bitkit no pudo leer tus etiquetas, así que el cambio de etiquetas no se guardó. Inténtalo de nuevo. 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 61dfa7dc16..120d1da81c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1338,6 +1338,7 @@ No s\'ha pogut desar el perfil Perfil desat ETIQUETES + Bitkit no ha pogut llegir les teves etiquetes, així que el canvi d\'etiquetes no s\'ha desat. Torna-ho a provar. 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 d4fa92fdf1..496fc048a3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1340,6 +1340,7 @@ Profil se nepodařilo uložit Profil uložen TAGY + Bitkit nemohl načíst vaše štítky, takže změna štítků nebyla uložena. Zkuste to 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 79def64fcd..33b7a1f580 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1338,6 +1338,7 @@ Profil konnte nicht gespeichert werden Profil gespeichert TAGS + Bitkit konnte deine Tags nicht lesen, daher wurde die Tag-Änderung nicht gespeichert. Versuche es 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 0585269ea1..3e934035c9 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1323,6 +1323,7 @@ Αποτυχία αποθήκευσης προφίλ Το προφίλ αποθηκεύτηκε ΕΤΙΚΕΤΕΣ + Το Bitkit δεν μπόρεσε να διαβάσει τις ετικέτες σου, οπότε η αλλαγή ετικετών δεν αποθηκεύτηκε. Δοκίμασε ξανά. ΑΠΟΣΥΝΔΕΣΗ Δεν ήταν δυνατή η φόρτωση του προφίλ σου. Με το φορητό προφίλ σου, οι επαφές σου μπορούν να σε βρουν και να σε πληρώσουν οπουδήποτε. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 9007c0d1a8..d887503d69 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1336,6 +1336,7 @@ No se pudo guardar el perfil Perfil guardado ETIQUETAS + Bitkit no ha podido leer tus etiquetas, así que el cambio de etiquetas no se ha guardado. Inténtalo de nuevo. 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 571ac39585..d8434bc40b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1335,6 +1335,7 @@ No se pudo guardar el perfil Perfil guardado ETIQUETAS + Bitkit no pudo leer tus etiquetas, así que el cambio de etiquetas no se guardó. Inténtalo de nuevo. 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 42f0cbaa11..5127f9421a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1336,6 +1336,7 @@ Échec de l\'enregistrement du profil Profil enregistré ÉTIQUETTES + Bitkit n\'a pas pu lire vos étiquettes, la modification des étiquettes n\'a donc pas été enregistrée. Réessayez. 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 a8505052c9..d45d4a2b05 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1341,6 +1341,7 @@ Impossibile salvare il profilo Profilo salvato TAG + Bitkit non è riuscito a leggere i tuoi tag, quindi la modifica dei tag non è stata salvata. Riprova. 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 4d27460f6c..f58e8a0fdc 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1338,6 +1338,7 @@ Profiel opslaan mislukt Profiel opgeslagen TAGS + Bitkit kon je tags niet lezen, dus de tagwijziging is niet opgeslagen. Probeer het 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 9d626d5926..908da5f289 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1339,6 +1339,7 @@ Nie udało się zapisać profilu Profil zapisany TAGI + Bitkit nie mógł odczytać Twoich tagów, więc zmiana tagów nie została zapisana. Spróbuj ponownie. 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 17b79e661d..b17a91068e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1341,6 +1341,7 @@ Falha ao salvar perfil Perfil salvo TAGS + O Bitkit não conseguiu ler suas tags, então a alteração das tags não foi salva. Tente 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 b83953fd9a..b87777db08 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1316,6 +1316,7 @@ Falha ao guardar o perfil Perfil guardado ETIQUETAS + O Bitkit não conseguiu ler as suas etiquetas, por isso a alteração das etiquetas não foi guardada. Tente 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 edcf94b7d4..6c64e9b06a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1356,6 +1356,7 @@ Не удалось сохранить профиль Профиль сохранён ТЕГИ + Bitkit не удалось прочитать ваши теги, поэтому изменение тегов не сохранено. Попробуйте ещё раз. ОТВЯЗКА Не удалось загрузить ваш профиль. С переносимым профилем ваши контакты смогут связаться с вами и заплатить вам где угодно. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 808d19d002..1115f1e2a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -650,6 +650,7 @@ Failed to save profile Profile saved TAGS + Bitkit could not read your tags, so the tag change was not saved. Try 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 146f49dde5..ff2eb74762 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -994,6 +994,79 @@ class PubkyRepoTest : BaseUnitTest() { 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 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 + ) + + assertIs(result.exceptionOrNull()) + 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 `deleteProfile deletes the pubky app profile of a wallet identity`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) @@ -1015,7 +1088,11 @@ class PubkyRepoTest : BaseUnitTest() { bio = "line one\nline two", links = listOf( PubkyProfileDataLink("Site", "example.com"), - PubkyProfileDataLink("Mail", "a@b.com") + PubkyProfileDataLink("Mail", "a@b.com"), + PubkyProfileDataLink("Medium", "medium.com/@alice"), + PubkyProfileDataLink("City", "New York"), + PubkyProfileDataLink("Date", "2024-01-01"), + PubkyProfileDataLink("Phone", "+49 123 456"), ), ).toPaykitProfile(), path = "/pub/paykit/profile.json", @@ -1035,6 +1112,8 @@ class PubkyRepoTest : BaseUnitTest() { 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 = "Phone", url = "tel:+49123456"), ), ), null, From 409d7441b14affbaa90eda7915bf0aad0eb088ce Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 22:25:13 +0100 Subject: [PATCH 12/22] fix: move only paykit avatars and keep the revision when tags fail --- .../java/to/bitkit/repositories/PubkyRepo.kt | 24 ++++++++++--------- .../screens/profile/EditProfileViewModel.kt | 6 +++-- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-b+es+419/strings.xml | 2 +- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-el/strings.xml | 2 +- app/src/main/res/values-es-rES/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 3 ++- 19 files changed, 35 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 594acfbf41..10f32adc3e 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -122,7 +122,9 @@ data class ProfileEditBase(val profile: PubkyProfile?, val revision: String?) /** 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) -class PubkyProfileTagsUnavailableError : AppError("Profile tags could not be read") +/** 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") @@ -1266,12 +1268,12 @@ class PubkyRepo @Inject constructor( .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } .exceptionOrNull() } - val tagError = when { - keptTags == null && tags != baseTags -> PubkyProfileTagsUnavailableError() - copyError != null && keptTags != baseTags -> copyError - else -> null - } val stored = if (keptTags != null && copyError == null) profile else profile.copy(tags = baseTags) + val tagsLost = (keptTags == null || copyError != null) && tags != baseTags + val tagError = PubkyProfileTagsNotSavedError( + ProfileEditBase(stored, snapshot.revision), + copyError + ).takeIf { tagsLost } return SavedProfile(stored, snapshot.revision, tagError) } @@ -1297,10 +1299,10 @@ class PubkyRepo @Inject constructor( var snapshot = fetched ?: paykitData?.takeIf { migrating }?.let { migrateToPubkyApp(publicKey, it) } // 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 - var retryingAvatar = fetched != null && publicKey in stored.pubkyAppAvatarPending && pendingAvatar != null - if (retryingAvatar && fetched?.profile?.image != null) { - // An avatar set on pubky.app meanwhile wins over the one still to move. + val pendingAvatar = paykitData?.image?.takeIf { it.isPaykitStorage() } + var retryingAvatar = fetched != null && publicKey in stored.pubkyAppAvatarPending + if (retryingAvatar && (pendingAvatar == null || fetched?.profile?.image != null)) { + // An avatar set on pubky.app meanwhile wins, and only an avatar on Paykit storage is moved. pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - publicKey) } retryingAvatar = false } @@ -1335,7 +1337,7 @@ class PubkyRepo @Inject constructor( ) val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, null, publicKey) } Logger.info("Moved Bitkit profile to pubky.app", context = TAG) - val image = data.image ?: return@runSuspendCatching snapshot + val image = data.image?.takeIf { it.isPaykitStorage() } ?: return@runSuspendCatching snapshot moveAvatar(publicKey, image, snapshot) ?: snapshot }.onFailure { Logger.warn("Failed to move Bitkit profile to pubky.app", it, context = TAG) }.getOrNull() 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 13df38706d..e5e2ae2275 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 @@ -29,7 +29,7 @@ import to.bitkit.models.takeCodePoints import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.ProfileEditBase import to.bitkit.repositories.PubkyProfileChangedError -import to.bitkit.repositories.PubkyProfileTagsUnavailableError +import to.bitkit.repositories.PubkyProfileTagsNotSavedError import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.components.ProfileEditLink import to.bitkit.ui.shared.toast.ToastEventBus @@ -230,11 +230,13 @@ class EditProfileViewModel @Inject constructor( title = context.getString(R.string.profile__edit_save_error), description = when { changed -> context.getString(R.string.profile__edit_changed_error) - it is PubkyProfileTagsUnavailableError -> context.getString(R.string.profile__edit_tags_error) + it is PubkyProfileTagsNotSavedError -> context.getString(R.string.profile__edit_tags_error) else -> it.message }, ) if (changed) reloadAfterExternalEdit() + // The other fields are on pubky.app, so a retry starts from their revision. + if (it is PubkyProfileTagsNotSavedError) editBase = it.base } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 649c5874f7..a5ec1668ad 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1317,7 +1317,7 @@ فشل حفظ الملف الشخصي تم حفظ الملف الشخصي العلامات - تعذّر على Bitkit قراءة وسومك، لذا لم يُحفظ تغيير الوسوم. حاول مرة أخرى. + تعذّر على 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 a3b465720e..9a8972bb90 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1342,7 +1342,7 @@ No se pudo guardar el perfil Perfil guardado ETIQUETAS - Bitkit no pudo leer tus etiquetas, así que el cambio de etiquetas no se guardó. Inténtalo de nuevo. + 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 120d1da81c..7b264286d9 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1338,7 +1338,7 @@ No s\'ha pogut desar el perfil Perfil desat ETIQUETES - Bitkit no ha pogut llegir les teves etiquetes, així que el canvi d\'etiquetes no s\'ha desat. Torna-ho a provar. + 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 496fc048a3..e45bb3ca95 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1340,7 +1340,7 @@ Profil se nepodařilo uložit Profil uložen TAGY - Bitkit nemohl načíst vaše štítky, takže změna štítků nebyla uložena. Zkuste to znovu. + 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 33b7a1f580..ecd7aba9a8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1338,7 +1338,7 @@ Profil konnte nicht gespeichert werden Profil gespeichert TAGS - Bitkit konnte deine Tags nicht lesen, daher wurde die Tag-Änderung nicht gespeichert. Versuche es erneut. + 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 3e934035c9..2b6c331357 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1323,7 +1323,7 @@ Αποτυχία αποθήκευσης προφίλ Το προφίλ αποθηκεύτηκε ΕΤΙΚΕΤΕΣ - Το Bitkit δεν μπόρεσε να διαβάσει τις ετικέτες σου, οπότε η αλλαγή ετικετών δεν αποθηκεύτηκε. Δοκίμασε ξανά. + Το Bitkit δεν μπόρεσε να αποθηκεύσει τις ετικέτες σου. Οι άλλες αλλαγές σου αποθηκεύτηκαν, οπότε δοκίμασε ξανά τις ετικέτες. ΑΠΟΣΥΝΔΕΣΗ Δεν ήταν δυνατή η φόρτωση του προφίλ σου. Με το φορητό προφίλ σου, οι επαφές σου μπορούν να σε βρουν και να σε πληρώσουν οπουδήποτε. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index d887503d69..f2d69bf454 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1336,7 +1336,7 @@ No se pudo guardar el perfil Perfil guardado ETIQUETAS - Bitkit no ha podido leer tus etiquetas, así que el cambio de etiquetas no se ha guardado. Inténtalo de nuevo. + 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 d8434bc40b..55ed5740c8 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1335,7 +1335,7 @@ No se pudo guardar el perfil Perfil guardado ETIQUETAS - Bitkit no pudo leer tus etiquetas, así que el cambio de etiquetas no se guardó. Inténtalo de nuevo. + 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 5127f9421a..dd524b325d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1336,7 +1336,7 @@ Échec de l\'enregistrement du profil Profil enregistré ÉTIQUETTES - Bitkit n\'a pas pu lire vos étiquettes, la modification des étiquettes n\'a donc pas été enregistrée. Réessayez. + 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 d45d4a2b05..8d71f295c0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1341,7 +1341,7 @@ Impossibile salvare il profilo Profilo salvato TAG - Bitkit non è riuscito a leggere i tuoi tag, quindi la modifica dei tag non è stata salvata. Riprova. + 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 f58e8a0fdc..865d73f199 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1338,7 +1338,7 @@ Profiel opslaan mislukt Profiel opgeslagen TAGS - Bitkit kon je tags niet lezen, dus de tagwijziging is niet opgeslagen. Probeer het opnieuw. + 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 908da5f289..f87670159d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1339,7 +1339,7 @@ Nie udało się zapisać profilu Profil zapisany TAGI - Bitkit nie mógł odczytać Twoich tagów, więc zmiana tagów nie została zapisana. Spróbuj ponownie. + 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 b17a91068e..0ba369aab0 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1341,7 +1341,7 @@ Falha ao salvar perfil Perfil salvo TAGS - O Bitkit não conseguiu ler suas tags, então a alteração das tags não foi salva. Tente novamente. + 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 b87777db08..07053a51ab 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1316,7 +1316,7 @@ Falha ao guardar o perfil Perfil guardado ETIQUETAS - O Bitkit não conseguiu ler as suas etiquetas, por isso a alteração das etiquetas não foi guardada. Tente novamente. + 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 6c64e9b06a..1c41d2d36c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1356,7 +1356,7 @@ Не удалось сохранить профиль Профиль сохранён ТЕГИ - Bitkit не удалось прочитать ваши теги, поэтому изменение тегов не сохранено. Попробуйте ещё раз. + Bitkit не удалось сохранить ваши теги. Остальные изменения сохранены, поэтому попробуйте сохранить теги ещё раз. ОТВЯЗКА Не удалось загрузить ваш профиль. С переносимым профилем ваши контакты смогут связаться с вами и заплатить вам где угодно. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1115f1e2a2..e4d97be9b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -650,7 +650,7 @@ Failed to save profile Profile saved TAGS - Bitkit could not read your tags, so the tag change was not saved. Try again. + 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 ff2eb74762..1f1699a7bf 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1033,7 +1033,8 @@ class PubkyRepoTest : BaseUnitTest() { imageUrl = null ) - assertIs(result.exceptionOrNull()) + val error = assertIs(result.exceptionOrNull()) + assertEquals("revision-", error.base.revision) verifyBlocking(pubkyService, never()) { publishPaykitProfile(any(), any()) } } From 4b8da17ce7c51881923040b9adafc1159f5c094e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 23:27:54 +0100 Subject: [PATCH 13/22] fix: fit restored profiles and keep tags and avatars across failed copy reads --- .../java/to/bitkit/repositories/PubkyRepo.kt | 93 +++++++++++++------ .../to/bitkit/repositories/PubkyRepoTest.kt | 63 +++++++++++++ 2 files changed, 128 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 10f32adc3e..12382b6021 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -130,6 +130,10 @@ 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. */ @@ -1122,7 +1126,8 @@ class PubkyRepo @Inject constructor( requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } - val pk = requireNotNull(_publicKey.value) { "No public key available" } + val signIn = requireNotNull(currentSignIn()) { "No public key available" } + val pk = signIn.publicKey val saved = runSuspendCatching { writeProfile( pk, @@ -1140,6 +1145,8 @@ class PubkyRepo @Inject constructor( 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) @@ -1227,13 +1234,17 @@ class PubkyRepo @Inject constructor( base: PubkyProfile?, expectedRevision: String?, ): SavedProfile { - val links = links.map { + val copyUnread = 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 = bio.singleLine() + 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( @@ -1250,41 +1261,67 @@ class PubkyRepo @Inject constructor( ) val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision, publicKey) } ownProfileRevision = snapshot.revision - val avatarPending = paykitAvatar != null && snapshot.profile.image == null + val baseTags = base?.tags.orEmpty() + val stored = storedCopy(publicKey) + 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 baseTags = base?.tags.orEmpty() - val keptTags = tagsToKeep(publicKey, tags) + 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 = if (avatarPending) paykitAvatar else it.imageUrl) + 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 { pubkyService.publishPaykitProfile(copyData, publicKey) } + .onSuccess { tagsUnavailable = false } .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } .exceptionOrNull() } - val stored = if (keptTags != null && copyError == null) profile else profile.copy(tags = baseTags) + 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(stored, snapshot.revision), + ProfileEditBase(saved, snapshot.revision), copyError ).takeIf { tagsLost } - return SavedProfile(stored, snapshot.revision, tagError) + return SavedProfile(saved, snapshot.revision, tagError) } - /** The stored tags plus any new ones; null while the Paykit copy still cannot be read. */ - private suspend fun tagsToKeep(publicKey: String, tags: List): List? { - if (!tagsUnavailable) return tags - val stored = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) }.getOrElse { return null } - tagsUnavailable = false - val storedTags = stored?.let { PubkyProfileData.fromPaykitProfile(it.profile).tags }.orEmpty() - return storedTags + tags.filterNot { it in storedTags } - } + /** + * 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): StoredCopy? { + if (!tagsUnavailable) 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 paykitRead = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) } @@ -1323,17 +1360,14 @@ class PubkyRepo @Inject constructor( private suspend fun migrateToPubkyApp(publicKey: String, data: PubkyProfileData): 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 = data.name.singleLine().takeCodePoints(PUBKY_APP_NAME_MAX_LENGTH), - bio = data.bio.singleLine().takeCodePoints(PUBKY_APP_BIO_MAX_LENGTH), - image = null, - links = data.links - .mapNotNull { link -> - val title = link.label.singleLine().takeIf { it.isNotEmpty() } ?: return@mapNotNull null - val url = link.url.toPubkyAppLinkUrl() ?: return@mapNotNull null - SdkPubkyProfileLink(title = title.takeCodePoints(PUBKY_APP_LINK_TITLE_MAX_LENGTH), url = url) - } - .take(PUBKY_APP_LINKS_MAX_COUNT), + name = fitted.name, + bio = fitted.bio, + image = keptImage, + links = fitted.links.map { SdkPubkyProfileLink(title = it.label, url = it.url) }, ) val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, null, publicKey) } Logger.info("Moved Bitkit profile to pubky.app", context = TAG) @@ -1398,6 +1432,9 @@ class PubkyRepo @Inject constructor( 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) { runSuspendCatching { pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile(), publicKey) } .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 1f1699a7bf..0f6fdfaaef 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -56,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 @@ -1068,6 +1069,60 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { fetchFile(any(), 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 `deleteProfile deletes the pubky app profile of a wallet identity`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) @@ -5394,6 +5449,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 = "", From c6e327804f479b9133f1722b8e73d801e4c1894b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:14:07 +0100 Subject: [PATCH 14/22] fix: reject numeric link hosts paykit refuses --- .../java/to/bitkit/repositories/PubkyRepo.kt | 18 ++++++++++++++---- .../to/bitkit/repositories/PubkyRepoTest.kt | 3 +++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 12382b6021..cea122ba7b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -192,6 +192,7 @@ class PubkyRepo @Inject constructor( 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 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 @@ -1410,15 +1411,24 @@ class PubkyRepo @Inject constructor( val url = when { !isPhone && runCatching { URI(value).scheme }.getOrNull() != null -> value value.isEmailAddress() -> "mailto:$value" - isPhone -> "tel:" + value.filterNot { it in " -()" } + 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 && - runCatching { URI(url).scheme }.getOrNull() != null + parsed?.scheme != null && parsed.hasValidHost() return url.takeIf { valid } } + /** Paykit rejects a host whose last label is all digits unless the host is an IPv4 address, as URL parsers do. */ + private fun URI.hasValidHost(): Boolean { + val host = host ?: return true + if (!host.substringAfterLast('.').all { it.isDigit() }) return true + val octets = host.split('.') + return octets.size == 4 && octets.all { it.isNotEmpty() && it.all(Char::isDigit) && it.toInt() <= 255 } + } + private fun String.isEmailAddress(): Boolean { val parts = split('@') return parts.size == 2 && parts[0].isNotEmpty() && '.' in parts[1] && '/' !in this @@ -1426,8 +1436,8 @@ class PubkyRepo @Inject constructor( 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) + 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 diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 0f6fdfaaef..79a5a77adf 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1149,6 +1149,8 @@ class PubkyRepoTest : BaseUnitTest() { PubkyProfileDataLink("City", "New York"), PubkyProfileDataLink("Date", "2024-01-01"), PubkyProfileDataLink("Phone", "+49 123 456"), + PubkyProfileDataLink("Dotted", "555.123.4567"), + PubkyProfileDataLink("Version", "v1.2.3"), ), ).toPaykitProfile(), path = "/pub/paykit/profile.json", @@ -1170,6 +1172,7 @@ class PubkyRepoTest : BaseUnitTest() { SdkPubkyProfileLink(title = "Mail", url = "mailto:a@b.com"), SdkPubkyProfileLink(title = "Medium", url = "https://medium.com/@alice"), SdkPubkyProfileLink(title = "Phone", url = "tel:+49123456"), + SdkPubkyProfileLink(title = "Dotted", url = "tel:5551234567"), ), ), null, From ea0fd45ba66f2c02ec905ed5bf6eef190c9b1b43 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:46:10 +0100 Subject: [PATCH 15/22] fix: check numeric link hosts only for web urls --- .../java/to/bitkit/repositories/PubkyRepo.kt | 16 ++++++++++++---- .../java/to/bitkit/repositories/PubkyRepoTest.kt | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index cea122ba7b..41b6e2318a 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -192,6 +192,7 @@ class PubkyRepo @Inject constructor( 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 @@ -1421,12 +1422,19 @@ class PubkyRepo @Inject constructor( return url.takeIf { valid } } - /** Paykit rejects a host whose last label is all digits unless the host is an IPv4 address, as URL parsers do. */ + /** + * 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 { - val host = host ?: return true + 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 octets = host.split('.') - return octets.size == 4 && octets.all { it.isNotEmpty() && it.all(Char::isDigit) && it.toInt() <= 255 } + 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 { diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 79a5a77adf..ca31b30123 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1148,9 +1148,9 @@ class PubkyRepoTest : BaseUnitTest() { PubkyProfileDataLink("Medium", "medium.com/@alice"), PubkyProfileDataLink("City", "New York"), PubkyProfileDataLink("Date", "2024-01-01"), - PubkyProfileDataLink("Phone", "+49 123 456"), PubkyProfileDataLink("Dotted", "555.123.4567"), PubkyProfileDataLink("Version", "v1.2.3"), + PubkyProfileDataLink("Loopback", "https://127.1"), ), ).toPaykitProfile(), path = "/pub/paykit/profile.json", @@ -1171,8 +1171,8 @@ class PubkyRepoTest : BaseUnitTest() { 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 = "Phone", url = "tel:+49123456"), SdkPubkyProfileLink(title = "Dotted", url = "tel:5551234567"), + SdkPubkyProfileLink(title = "Loopback", url = "https://127.1"), ), ), null, From 8e624bad676f29e1a4ae2d28783c6f0c28a9884b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 9 Nov 2026 10:34:29 +0100 Subject: [PATCH 16/22] fix: keep pubky.app fields after a failed profile lookup Create Profile clears the restore base before the lookup, so a failed lookup no longer saves a blank form against an earlier revision. Tag edits on Profile show the translated changed-in-pubky.app and tags-not-saved errors. --- .../java/to/bitkit/repositories/PubkyRepo.kt | 3 ++- .../screens/profile/EditProfileViewModel.kt | 12 +++++++----- .../ui/screens/profile/ProfileViewModel.kt | 2 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 41b6e2318a..365ec70f34 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -921,8 +921,9 @@ class PubkyRepo @Inject constructor( 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) } - val snapshot = readRetryingOnce(prefixedKey) { pubkyService.fetchPubkyProfileWithRevision(prefixedKey) } + // 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 { 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 e5e2ae2275..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 @@ -228,11 +228,7 @@ class EditProfileViewModel @Inject constructor( ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.profile__edit_save_error), - description = when { - changed -> context.getString(R.string.profile__edit_changed_error) - it is PubkyProfileTagsNotSavedError -> context.getString(R.string.profile__edit_tags_error) - else -> it.message - }, + description = it.profileSaveErrorText(context), ) if (changed) reloadAfterExternalEdit() // The other fields are on pubky.app, so a retry starts from their revision. @@ -380,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/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index ca31b30123..841615ccac 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -64,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 @@ -1007,6 +1008,24 @@ class PubkyRepoTest : BaseUnitTest() { 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) From 917429df66bcbd524cad0bc1fffe81015d376d10 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 17:06:37 +0100 Subject: [PATCH 17/22] fix: keep a deleted profile from being written back --- .../java/to/bitkit/repositories/PubkyRepo.kt | 82 ++++++++++++++----- .../to/bitkit/repositories/PubkyRepoTest.kt | 35 ++++++++ 2 files changed, 96 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 365ec70f34..1f62efb23b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -223,6 +223,11 @@ class PubkyRepo @Inject constructor( 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) + /** Revision of the loaded pubky.app profile, which a save must still match; null when none exists. */ @Volatile private var ownProfileRevision: String? = null @@ -1175,14 +1180,21 @@ class PubkyRepo @Inject constructor( } clearSessionContactProfiles() deleteAllContacts() - if (!isRingAdoptedIdentity()) deletePubkyAppProfile() - 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() } @@ -1237,6 +1249,7 @@ class PubkyRepo @Inject constructor( base: PubkyProfile?, expectedRevision: String?, ): SavedProfile { + val generation = profileDeletionGeneration.get() val copyUnread = 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 @@ -1262,7 +1275,9 @@ class PubkyRepo @Inject constructor( links = links.takeIf { base == null || it != base.links } ?.map { SdkPubkyProfileLink(title = it.label, url = it.url) }, ) - val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision, publicKey) } + val snapshot = profileWrite(generation) { + withPubkyAppScope { pubkyService.publishPubkyProfile(update, expectedRevision, publicKey) } + } ownProfileRevision = snapshot.revision val baseTags = base?.tags.orEmpty() val stored = storedCopy(publicKey) @@ -1288,7 +1303,7 @@ class PubkyRepo @Inject constructor( // 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 { pubkyService.publishPaykitProfile(copyData, publicKey) } + runSuspendCatching { profileWrite(generation) { pubkyService.publishPaykitProfile(copyData, publicKey) } } .onSuccess { tagsUnavailable = false } .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } .exceptionOrNull() @@ -1327,6 +1342,7 @@ class PubkyRepo @Inject constructor( ) private suspend fun loadOwnProfile(publicKey: String): Pair? { + val generation = profileDeletionGeneration.get() val paykitRead = runSuspendCatching { pubkyService.fetchPaykitProfile(publicKey) } .onFailure { Logger.warn("Failed to read Bitkit profile copy", it, context = TAG) } tagsUnavailable = paykitRead.isFailure @@ -1336,7 +1352,7 @@ class PubkyRepo @Inject constructor( 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) } + 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() } @@ -1346,7 +1362,9 @@ class PubkyRepo @Inject constructor( pubkyStore.update { it.copy(pubkyAppAvatarPending = it.pubkyAppAvatarPending - publicKey) } retryingAvatar = false } - if (retryingAvatar) snapshot = moveAvatar(publicKey, checkNotNull(pendingAvatar), snapshot) ?: snapshot + if (retryingAvatar) { + snapshot = moveAvatar(publicKey, checkNotNull(pendingAvatar), snapshot, generation) ?: snapshot + } if (!hadPubkyAppProfile) { pubkyStore.update { it.copy(pubkyAppProfileOwners = it.pubkyAppProfileOwners + publicKey) } } @@ -1356,12 +1374,16 @@ class PubkyRepo @Inject constructor( it.copy(tags = paykitData?.tags.orEmpty(), imageUrl = if (avatarPending) pendingAvatar else it.imageUrl) } if (!avatarPending && paykitData != null && paykitData != profile.toProfileData()) { - publishPaykitProfileCopy(publicKey, profile) + publishPaykitProfileCopy(publicKey, profile, generation) } return profile to snapshot.revision } - private suspend fun migrateToPubkyApp(publicKey: String, data: PubkyProfileData): PubkyProfileSnapshot? = + 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. @@ -1372,16 +1394,19 @@ class PubkyRepo @Inject constructor( image = keptImage, links = fitted.links.map { SdkPubkyProfileLink(title = it.label, url = it.url) }, ) - val snapshot = withPubkyAppScope { pubkyService.publishPubkyProfile(update, null, publicKey) } + val snapshot = profileWrite(generation) { + withPubkyAppScope { pubkyService.publishPubkyProfile(update, null, publicKey) } + } Logger.info("Moved Bitkit profile to pubky.app", context = TAG) val image = data.image?.takeIf { it.isPaykitStorage() } ?: return@runSuspendCatching snapshot - moveAvatar(publicKey, image, snapshot) ?: 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()) { @@ -1390,9 +1415,15 @@ class PubkyRepo @Inject constructor( "gif" -> "image/gif" else -> "image/jpeg" } - val image = withPubkyAppScope { pubkyService.uploadPubkyAppAvatar(bytes, contentType, publicKey) } - withPubkyAppScope { - pubkyService.publishPubkyProfile(PubkyProfileUpdate(null, null, image, null), snapshot.revision, publicKey) + 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) } @@ -1454,9 +1485,18 @@ class PubkyRepo @Inject constructor( 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) { - runSuspendCatching { pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile(), publicKey) } - .onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } + private suspend fun publishPaykitProfileCopy(publicKey: String, profile: PubkyProfile, generation: Long) { + runSuspendCatching { + profileWrite(generation) { + 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 { diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 841615ccac..86bc7d8fe0 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1151,6 +1151,41 @@ class PubkyRepoTest : BaseUnitTest() { 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 `loadProfile moves a multi-line bio and scheme-less links to pubky app`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) From 033064ae9f40b2ed3e4b55fe7301cec751a3ab5b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 18:19:46 +0100 Subject: [PATCH 18/22] test: fix fixtures doubled by the master merge --- .../java/to/bitkit/repositories/PaykitIssuerInteropTest.kt | 2 -- .../java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt | 2 -- .../repositories/PaykitPaymentRequestRepoSubscriptionTest.kt | 2 -- .../to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt | 3 --- .../java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt | 2 -- .../test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt | 2 -- app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt | 4 ++-- app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt | 1 - 8 files changed, 2 insertions(+), 16 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt index 0e04d8dd74..3c3ff2d45a 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitIssuerInteropTest.kt @@ -135,7 +135,6 @@ class PaykitIssuerInteropTest { ), acceptedEventId = null, acceptedOutboundStatus = null, - acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, @@ -148,7 +147,6 @@ class PaykitIssuerInteropTest { lastEventAt = NOW.toString(), invalidReason = null, proposalAppId = "bitkit", - proposalRecordedAt = null, payerAppId = null, executionClaimAppId = null, proposalRecordedAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index a78bd373c4..4ea9a154cb 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -3705,7 +3705,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), acceptedEventId = null, acceptedOutboundStatus = null, - acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, @@ -3718,7 +3717,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastEventAt = "2027-01-15T08:00:00Z", invalidReason = null, proposalAppId = "bitkit", - proposalRecordedAt = null, payerAppId = null, executionClaimAppId = null, proposalRecordedAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index af2e0ffea4..29c6e05819 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -1678,7 +1678,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat ), acceptedEventId = null, acceptedOutboundStatus = null, - acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, @@ -1691,7 +1690,6 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat lastEventAt = clock.now().toString(), invalidReason = null, proposalAppId = "bitkit", - proposalRecordedAt = null, payerAppId = null, executionClaimAppId = null, proposalRecordedAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 0540587c87..4da1a00f2e 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -1049,7 +1049,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { val record = paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE).let { it.copy( proposalAppId = "marketplace", - proposalRecordedAt = null, terms = requireNotNull(it.terms).copy( acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue), paymentEndpoints = mapOf( @@ -3679,7 +3678,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ), acceptedEventId = null, acceptedOutboundStatus = null, - acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, @@ -3692,7 +3690,6 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { lastEventAt = clock.now().toString(), invalidReason = null, proposalAppId = "bitkit", - proposalRecordedAt = null, payerAppId = null, executionClaimAppId = null, proposalRecordedAt = null, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt index 9e983357ee..49f98c7539 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitUsdtPaymentRepoTest.kt @@ -876,7 +876,6 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { proposalStreamItemId = 1u, proposalOutboundMessageId = null, proposalOutboundStatus = null, proposalEventId = "proposal", proposalAppId = "bitkit", - proposalRecordedAt = null, payerAppId = "bitkit", executionClaimAppId = "bitkit", proposalRecordedAt = proposalRecordedAt, @@ -893,7 +892,6 @@ class PaykitUsdtPaymentRepoTest : BaseUnitTest() { mock() ), acceptedEventId = "accepted", acceptedOutboundStatus = null, - acceptanceRecordedAt = null, rejectedEventId = null, rejectedOutboundStatus = null, canceledEventId = null, canceledOutboundStatus = null, paymentProofs = listOf( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 106537715f..d1377085d3 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -4895,7 +4895,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { error = null, ) }, - acknowledged = emptyList(), failedToQueue = failedToQueue, failedToDeliver = failedToDeliver, acknowledged = emptyList(), @@ -4914,7 +4913,6 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { cleared = updates .filter { it.reservations.isEmpty() } .map { privateListSyncChange(it.counterparty) }, - acknowledged = emptyList(), failedToQueue = emptyList(), failedToDeliver = emptyList(), acknowledged = emptyList(), diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 017b379bab..c3312cbaba 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -766,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() @@ -791,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 diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 51d5b1f517..9f723fd546 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -3473,7 +3473,6 @@ class PaykitSdkServiceTest { emptyList() }, failedToDeliver = emptyList(), - acknowledged = emptyList(), ) } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } From 6fd67b6b1c7a0dd2203a4b8eb7ce6a43a4191fca Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 22:21:33 +0100 Subject: [PATCH 19/22] fix: keep a load under way from writing back replaced tags --- .../java/to/bitkit/repositories/PubkyRepo.kt | 34 +++++++++++++++++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 28 +++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 1f62efb23b..3416fae266 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -228,6 +228,10 @@ class PubkyRepo @Inject constructor( private val profileWriteMutex = Mutex() private val profileDeletionGeneration = AtomicLong(0L) + // Moved on when a save starts and when it ends, so odd while one runs. A load writes the Paykit copy back only when + // no save ran since it read the copy, so it never writes back tags a save replaced. + private val profileSaveEpoch = AtomicLong(0L) + /** Revision of the loaded pubky.app profile, which a save must still match; null when none exists. */ @Volatile private var ownProfileRevision: String? = null @@ -1248,6 +1252,24 @@ class PubkyRepo @Inject constructor( imageUrl: String?, base: PubkyProfile?, expectedRevision: String?, + ): SavedProfile { + profileSaveEpoch.incrementAndGet() + try { + return writeProfileOnce(publicKey, name, bio, links, tags, imageUrl, base, expectedRevision) + } finally { + profileSaveEpoch.incrementAndGet() + } + } + + private suspend fun writeProfileOnce( + publicKey: String, + name: String, + bio: String, + links: List, + tags: List, + imageUrl: String?, + base: PubkyProfile?, + expectedRevision: String?, ): SavedProfile { val generation = profileDeletionGeneration.get() val copyUnread = tagsUnavailable @@ -1343,6 +1365,7 @@ class PubkyRepo @Inject constructor( 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 @@ -1374,7 +1397,7 @@ class PubkyRepo @Inject constructor( it.copy(tags = paykitData?.tags.orEmpty(), imageUrl = if (avatarPending) pendingAvatar else it.imageUrl) } if (!avatarPending && paykitData != null && paykitData != profile.toProfileData()) { - publishPaykitProfileCopy(publicKey, profile, generation) + publishPaykitProfileCopy(publicKey, profile, generation, saveEpoch) } return profile to snapshot.revision } @@ -1485,9 +1508,16 @@ class PubkyRepo @Inject constructor( 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) { + private suspend fun publishPaykitProfileCopy( + publicKey: String, + profile: PubkyProfile, + generation: Long, + saveEpoch: Long, + ) { runSuspendCatching { profileWrite(generation) { + // Checked under the write lock, so a save that starts after it writes its copy after this one. + if (saveEpoch % 2 != 0L || profileSaveEpoch.get() != saveEpoch) return@profileWrite pubkyService.publishPaykitProfile(profile.toProfileData().toPaykitProfile(), publicKey) } }.onFailure { Logger.warn("Failed to update Bitkit profile copy", it, context = TAG) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index c3312cbaba..d465cd7fa2 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1224,6 +1224,34 @@ class PubkyRepoTest : BaseUnitTest() { 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 `loadProfile moves a multi-line bio and scheme-less links to pubky app`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) From bda1793f2def1d54821ae29882ad24a6375559cf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 22:48:31 +0100 Subject: [PATCH 20/22] fix: keep a pending avatar move after a failed copy read --- .../java/to/bitkit/repositories/PubkyRepo.kt | 8 ++++--- .../to/bitkit/repositories/PubkyRepoTest.kt | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 3416fae266..16af58d10b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1380,11 +1380,13 @@ class PubkyRepo @Inject constructor( 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 - if (retryingAvatar && (pendingAvatar == null || fetched?.profile?.image != null)) { - // An avatar set on pubky.app meanwhile wins, and only an avatar on Paykit storage is moved. + 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) } - retryingAvatar = false } + if (pendingAvatar == null || avatarMoveSettled) retryingAvatar = false if (retryingAvatar) { snapshot = moveAvatar(publicKey, checkNotNull(pendingAvatar), snapshot, generation) ?: snapshot } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index d465cd7fa2..b35492a342 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1126,6 +1126,29 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { fetchFile(any(), any()) } } + @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) From 521c3be2b4203c9545086d978e16d7e78e7aa2ea Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:22:29 +0100 Subject: [PATCH 21/22] fix: record an avatar move before creating the pubky.app profile --- .../java/to/bitkit/repositories/PubkyRepo.kt | 8 +++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 16af58d10b..0aadcf7ef4 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1419,11 +1419,17 @@ class PubkyRepo @Inject constructor( 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) - val image = data.image?.takeIf { it.isPaykitStorage() } ?: return@runSuspendCatching snapshot + 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() diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index b35492a342..c09e5641da 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1126,6 +1126,31 @@ class PubkyRepoTest : BaseUnitTest() { 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( From cb4d9cdf4117311f4d474f339947101423c72d0f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:46:27 +0100 Subject: [PATCH 22/22] fix: keep the stored tags when an edit began without them A save merges the stored tags when its edit began while the Paykit copy was unread, not only when it is unread now, and a load refreshes the copy only while no save runs. --- .../java/to/bitkit/repositories/PubkyRepo.kt | 37 +++++++---- .../to/bitkit/repositories/PubkyRepoTest.kt | 63 +++++++++++++++++++ 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 0aadcf7ef4..addc9a8c5b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -80,6 +80,7 @@ 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 @@ -116,8 +117,11 @@ 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. */ -data class ProfileEditBase(val profile: PubkyProfile?, val revision: String?) +/** + * 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) @@ -228,9 +232,10 @@ class PubkyRepo @Inject constructor( private val profileWriteMutex = Mutex() private val profileDeletionGeneration = AtomicLong(0L) - // Moved on when a save starts and when it ends, so odd while one runs. A load writes the Paykit copy back only when - // no save ran since it read the copy, so it never writes back tags a save replaced. + // 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 @@ -1116,7 +1121,7 @@ class PubkyRepo @Inject constructor( } /** The profile and pubky.app revision an edit starts from; its save is checked against that revision. */ - fun profileEditBase() = ProfileEditBase(_profile.value, ownProfileRevision) + fun profileEditBase() = ProfileEditBase(_profile.value, ownProfileRevision, tagsUnavailable) suspend fun saveProfile( name: String, @@ -1149,7 +1154,8 @@ class PubkyRepo @Inject constructor( tags, imageUrl, base = editBase.profile.takeIf { editBase.revision != null }, - expectedRevision = editBase.revision + expectedRevision = editBase.revision, + tagsUnread = editBase.tagsUnread, ) }.getOrElse { if (!it.isConcurrentUpdate()) throw it @@ -1252,11 +1258,14 @@ class PubkyRepo @Inject constructor( 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) + return writeProfileOnce(publicKey, name, bio, links, tags, imageUrl, base, expectedRevision, tagsUnread) } finally { + profileSavesRunning.decrementAndGet() profileSaveEpoch.incrementAndGet() } } @@ -1270,9 +1279,11 @@ class PubkyRepo @Inject constructor( imageUrl: String?, base: PubkyProfile?, expectedRevision: String?, + tagsUnread: Boolean, ): SavedProfile { val generation = profileDeletionGeneration.get() - val copyUnread = tagsUnavailable + // 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 { @@ -1302,7 +1313,7 @@ class PubkyRepo @Inject constructor( } ownProfileRevision = snapshot.revision val baseTags = base?.tags.orEmpty() - val stored = storedCopy(publicKey) + 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 @@ -1333,7 +1344,7 @@ class PubkyRepo @Inject constructor( 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), + ProfileEditBase(saved, snapshot.revision, tagsUnread = copyUnread), copyError ).takeIf { tagsLost } return SavedProfile(saved, snapshot.revision, tagError) @@ -1343,8 +1354,8 @@ class PubkyRepo @Inject constructor( * 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): StoredCopy? { - if (!tagsUnavailable) return StoredCopy(tags = null, image = null) + 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) @@ -1525,7 +1536,7 @@ class PubkyRepo @Inject constructor( runSuspendCatching { profileWrite(generation) { // Checked under the write lock, so a save that starts after it writes its copy after this one. - if (saveEpoch % 2 != 0L || profileSaveEpoch.get() != saveEpoch) return@profileWrite + 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) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index c09e5641da..e4b8b77df3 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1228,6 +1228,32 @@ class PubkyRepoTest : BaseUnitTest() { 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) @@ -1300,6 +1326,43 @@ class PubkyRepoTest : BaseUnitTest() { 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)