Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e10b071
feat: use the pubky.app profile directly
ovitrif Nov 6, 2026
4da77f0
fix: keep the newest profile revision and move profiles text first
ovitrif Nov 6, 2026
8e0c0d9
fix: run pubky.app profile calls on the pubky queue
ovitrif Nov 6, 2026
d9b66f8
chore: add changelog fragment
ovitrif Nov 6, 2026
3e57c25
fix: keep a profile deleted on pubky.app deleted
ovitrif Nov 6, 2026
a42bc35
fix: save a multi-line bio as one line
ovitrif Nov 6, 2026
ff8a1e1
fix: delete the pubky.app profile and keep a pending avatar
ovitrif Nov 6, 2026
2f4f135
fix: move every bitkit profile to pubky.app and keep tags safe
ovitrif Nov 6, 2026
b17caec
fix: bind profile writes to their identity and edit
ovitrif Nov 6, 2026
077b8b0
fix: keep the repository's profile revision current on refresh
ovitrif Nov 6, 2026
046998c
fix: keep pubky.app edits on restore, avatar moves and tag saves
ovitrif Nov 6, 2026
409d744
fix: move only paykit avatars and keep the revision when tags fail
ovitrif Nov 6, 2026
4b8da17
fix: fit restored profiles and keep tags and avatars across failed co…
ovitrif Nov 6, 2026
c6e3278
fix: reject numeric link hosts paykit refuses
ovitrif Nov 6, 2026
ea0fd45
fix: check numeric link hosts only for web urls
ovitrif Nov 6, 2026
8e624ba
fix: keep pubky.app fields after a failed profile lookup
ovitrif Nov 9, 2026
917429d
fix: keep a deleted profile from being written back
ovitrif Nov 10, 2026
73553d4
Merge remote-tracking branch 'origin/master' into feat/pubky-app-profile
ovitrif Nov 10, 2026
033064a
test: fix fixtures doubled by the master merge
ovitrif Nov 10, 2026
6fd67b6
fix: keep a load under way from writing back replaced tags
ovitrif Nov 10, 2026
bda1793
fix: keep a pending avatar move after a failed copy read
ovitrif Nov 10, 2026
521c3be
fix: record an avatar move before creating the pubky.app profile
ovitrif Nov 10, 2026
cb4d9cd
fix: keep the stored tags when an edit began without them
ovitrif Nov 11, 2026
7296e0f
Merge remote-tracking branch 'origin/master' into feat/pubky-app-profile
ovitrif Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/src/main/java/to/bitkit/data/PubkyStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
}

Expand All @@ -41,6 +47,10 @@ data class PubkyStoreData(
val cachedName: String? = null,
val cachedImageUri: String? = null,
val contactProfileOverrides: Map<String, PubkyProfileData> = emptyMap(),
/** Identities Bitkit has seen a pubky.app profile for, so a profile deleted there is not moved back from Bitkit. */
val pubkyAppProfileOwners: Set<String> = emptySet(),
/** Identities whose Bitkit avatar is still to be moved to pubky.app, retried on each profile load. */
val pubkyAppAvatarPending: Set<String> = emptySet(),
) {
fun cachedProfile(): PubkyCachedProfile? = cachedProfileOwner?.let {
PubkyCachedProfile(publicKey = it, name = cachedName.orEmpty(), imageUri = cachedImageUri)
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyProfile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ import com.synonym.paykit.PubkyProfile as SdkPubkyProfile

private val pubkyProfileJson = Json { ignoreUnknownKeys = true }

/** pubky.app profile name limits from pubky-app-specs. */
const val PROFILE_NAME_MIN_LENGTH = 3
const val PROFILE_NAME_MAX_LENGTH = 50
const val PROFILE_LINKS_MAX_COUNT = 5

/** pubky.app counts code points, not UTF-16 chars. */
fun isValidProfileName(name: String) = name.trim().let { it.codePointCount(0, it.length) } in
PROFILE_NAME_MIN_LENGTH..PROFILE_NAME_MAX_LENGTH

fun String.takeCodePoints(max: Int) =
if (codePointCount(0, length) <= max) this else substring(0, offsetByCodePoints(0, max))

@Immutable
data class PubkyProfileLink(val label: String, val url: String)

Expand Down
576 changes: 493 additions & 83 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt

Large diffs are not rendered by default.

56 changes: 54 additions & 2 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -84,6 +86,7 @@ import com.synonym.paykit.defaultPubkyClientConfig
import com.synonym.paykit.parsePubkyAuthUrl
import com.synonym.paykit.paykitAuthorizerSessionCapabilities
import com.synonym.paykit.paymentProofEventLen
import com.synonym.paykit.pubkyAppProfileSessionCapabilities
import com.synonym.paykit.pubkyPublicKeyFromSecret
import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic
import dagger.hilt.android.qualifiers.ApplicationContext
Expand Down Expand Up @@ -622,11 +625,12 @@ class PaykitSdkService @Inject constructor(
publicRead(PaykitReadLane.Bulk, 5.seconds) { it.fetchPubkyFileBounded(uri, maxBytes) }
?: throw PubkyFileNotFoundError()

suspend fun publishPaykitProfile(profile: PaykitProfile): PaykitProfileRecord {
suspend fun publishPaykitProfile(profile: PaykitProfile, expectedIdentity: String): PaykitProfileRecord {
isSetup.await()
return operationLock.withLock {
withPaykitKey { handle ->
val publicKey = requireNotNull(completeSdkCall { handle.identityStatus() }?.publicKey)
requireProfileIdentity(publicKey, expectedIdentity)
val current = handle.fetchPaykitProfile(publicKey)
completeSdkCall {
handle.publishPaykitProfile(profile, current?.revision).also { notifyBackupStateChanged() }
Expand Down Expand Up @@ -668,6 +672,46 @@ class PaykitSdkService @Inject constructor(
suspend fun fetchPubkyProfile(publicKey: String): PubkyProfile? =
publicRead { it.fetchPubkyProfile(publicKey) }?.profile

suspend fun fetchPubkyProfileWithRevision(publicKey: String): PubkyProfileSnapshot? =
publicRead { it.fetchPubkyProfileWithRevision(publicKey) }

suspend fun fetchPaykitProfile(publicKey: String): PaykitProfileRecord? =
publicRead { it.fetchPaykitProfile(publicKey) }

suspend fun publishPubkyProfile(
update: PubkyProfileUpdate,
expectedRevision: String?,
expectedIdentity: String,
): PubkyProfileSnapshot {
isSetup.await()
return operationLock.withLock {
requireProfileIdentity(currentIdentity(), expectedIdentity)
completeSdkCall { handle().publishPubkyProfile(update, expectedRevision) }
}
}

suspend fun deletePubkyProfile(expectedRevision: String, expectedIdentity: String) {
isSetup.await()
operationLock.withLock {
requireProfileIdentity(currentIdentity(), expectedIdentity)
completeSdkCall { handle().deletePubkyProfile(expectedRevision) }
}
}

suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String, expectedIdentity: String): String {
isSetup.await()
return operationLock.withLock {
requireProfileIdentity(currentIdentity(), expectedIdentity)
completeSdkCall { handle().uploadPubkyAppAvatar(bytes, contentType) }
}
}

private suspend fun currentIdentity(): String? = completeSdkCall { handle().identityStatus() }?.publicKey

private fun requireProfileIdentity(current: String?, expectedIdentity: String) {
if (!PubkyPublicKeyFormat.matches(current, expectedIdentity)) throw PubkyProfileIdentityChangedError()
}

suspend fun fetchPubkyFollows(publicKey: String): List<String> =
publicRead { it.fetchPubkyFollows(publicKey, maxEntries = 10_000u) }

Expand Down Expand Up @@ -885,7 +929,12 @@ class PaykitSdkService @Inject constructor(
}
}

fun requiredCapabilities(): String = paykitAuthorizerSessionCapabilities()
/**
* Scope for sessions Bitkit creates: Paykit plus the shared pubky.app profile and its avatar.
* Restoring a saved session still checks only the Paykit scope, so sessions from older builds keep working.
*/
fun requiredCapabilities(): String =
"${paykitAuthorizerSessionCapabilities()},${pubkyAppProfileSessionCapabilities()}"

suspend fun syncPrivatePaymentListsWithReservations(
updates: List<PrivatePaymentListReservationUpdateInput>,
Expand Down Expand Up @@ -2277,3 +2326,6 @@ internal fun <T> 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")
30 changes: 28 additions & 2 deletions app/src/main/java/to/bitkit/services/PubkyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -199,8 +202,8 @@ class PubkyService @Inject constructor(

// region Profile & contacts

suspend fun publishPaykitProfile(profile: PaykitProfile) = ServiceQueue.PUBKY.background {
paykitSdkService.publishPaykitProfile(profile)
suspend fun publishPaykitProfile(profile: PaykitProfile, expectedIdentity: String) = ServiceQueue.PUBKY.background {
paykitSdkService.publishPaykitProfile(profile, expectedIdentity)
}

suspend fun uploadProfileAvatar(bytes: ByteArray, contentType: String): String = ServiceQueue.PUBKY.background {
Expand All @@ -211,6 +214,29 @@ class PubkyService @Inject constructor(
paykitSdkService.deletePaykitProfile()
}

suspend fun fetchPubkyProfileWithRevision(publicKey: String): PubkyProfileSnapshot? = cancellablePublicRead {
paykitSdkService.fetchPubkyProfileWithRevision(publicKey)
}

suspend fun fetchPaykitProfile(publicKey: String): PaykitProfileRecord? = cancellablePublicRead {
paykitSdkService.fetchPaykitProfile(publicKey)
}

suspend fun publishPubkyProfile(
update: PubkyProfileUpdate,
expectedRevision: String?,
expectedIdentity: String,
): PubkyProfileSnapshot = ServiceQueue.PUBKY.background {
paykitSdkService.publishPubkyProfile(update, expectedRevision, expectedIdentity)
}

suspend fun deletePubkyProfile(expectedRevision: String, expectedIdentity: String) = ServiceQueue.PUBKY.background {
paykitSdkService.deletePubkyProfile(expectedRevision, expectedIdentity)
}

suspend fun uploadPubkyAppAvatar(bytes: ByteArray, contentType: String, expectedIdentity: String): String =
ServiceQueue.PUBKY.background { paykitSdkService.uploadPubkyAppAvatar(bytes, contentType, expectedIdentity) }

suspend fun getContacts(publicKey: String): List<String> = cancellablePublicRead {
paykitSdkService.fetchPubkyFollows(publicKey)
}
Expand Down
44 changes: 24 additions & 20 deletions app/src/main/java/to/bitkit/ui/components/ProfileEditForm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import to.bitkit.R
import to.bitkit.models.PROFILE_NAME_MAX_LENGTH
import to.bitkit.models.PubkyProfileLink
import to.bitkit.models.Toast
import to.bitkit.models.takeCodePoints
import to.bitkit.repositories.PubkyProfileChangedError
import to.bitkit.repositories.PubkyRepo
import to.bitkit.ui.components.ProfileEditLink
import to.bitkit.ui.shared.toast.ToastEventBus
Expand Down Expand Up @@ -125,7 +128,7 @@ class CreateProfileViewModel @Inject constructor(
}

fun onNameChange(name: String) {
_uiState.update { it.copy(name = name) }
_uiState.update { it.copy(name = name.takeCodePoints(PROFILE_NAME_MAX_LENGTH)) }
}

fun onBioChange(bio: String) {
Expand Down Expand Up @@ -198,11 +201,13 @@ class CreateProfileViewModel @Inject constructor(
}.onFailure {
Logger.error("Failed to create identity", it, context = TAG)
_uiState.update { it.copy(isSaving = false) }
val changed = it is PubkyProfileChangedError
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.profile__create_error),
description = it.message,
description = if (changed) context.getString(R.string.profile__edit_changed_error) else it.message,
)
if (changed) deriveAndCheckRemote()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import kotlinx.collections.immutable.persistentListOf
import to.bitkit.R
import to.bitkit.models.PROFILE_LINKS_MAX_COUNT
import to.bitkit.models.isValidProfileName
import to.bitkit.ui.components.AddLinkSheet
import to.bitkit.ui.components.AddTagSheet
import to.bitkit.ui.components.GradientCircularProgressIndicator
Expand Down Expand Up @@ -166,10 +168,11 @@ private fun Content(
onAddTag = onAddTag,
onSave = onSave,
onCancel = onBackClick,
isSaveEnabled = uiState.name.isNotBlank() && !uiState.isBusy,
isSaveEnabled = isValidProfileName(uiState.name) && !uiState.isBusy,
topBar = topBar,
isSaving = uiState.isSaving,
isDeleting = uiState.isDeleting,
maxLinks = PROFILE_LINKS_MAX_COUNT,
avatarContent = {
AvatarSection(
imageUrl = uiState.imageUrl,
Expand Down
Loading
Loading