Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 87 additions & 18 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,20 @@ sealed class PubkyContactError(message: String) : AppError(message) {
data object CannotAddSelf : PubkyContactError("Cannot add your own pubky as a contact")
data object InvalidFormat : PubkyContactError("Invalid pubky key format")
data object ActiveSubscription : PubkyContactError("Contact has an active subscription")
data object SignInChanged : PubkyContactError("Pubky sign-in changed while saving the contact")
}

private fun Throwable.containsActiveSubscriptionError(): Boolean =
generateSequence(this) { it.cause }.any { it is PubkyContactError.ActiveSubscription }
/**
* One sign-in of a Pubky identity. Work the user starts in it, such as a contact edit that first waits for a profile
* lookup, checks [PubkyRepo.isCurrent] before it writes, so it stops once that identity signs out or the next sign-in
* starts. Every sign-in starts a new one, even when it signs in the same identity again. Adopting a Ring identity ends
* the current one before it installs the adopted session, so an adoption that fails at sign-in ends it too. Restoring
* or refreshing the session of the identity already signed in keeps it. It holds no secret.
*/
class PubkySignIn internal constructor(val publicKey: String, internal val generation: Long)

private fun Throwable.hasCause(error: PubkyContactError): Boolean =
generateSequence(this) { it.cause }.any { it == error }

private fun Throwable.isPaykitReadTimeout(): Boolean =
generateSequence(this) { it.cause }.any { it is PaykitReadTimeoutError }
Expand Down Expand Up @@ -152,6 +162,7 @@ class PubkyRepo @Inject constructor(
private val adoptedSourceCheckMutex = Mutex()
private val adoptionMutex = Mutex()
private val profileWriteGeneration = AtomicLong(0L)
private val signInGeneration = AtomicLong(0L)
private var isServiceInitialized = false

private val _profile = MutableStateFlow<PubkyProfile?>(null)
Expand Down Expand Up @@ -394,7 +405,7 @@ class PubkyRepo @Inject constructor(
}
is InitResult.Restored -> {
_sessionRestorationFailed.update { false }
_publicKey.update { result.publicKey }
continueSignIn(result.publicKey)
Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG)
}
is InitResult.RestorationFailed -> {
Expand Down Expand Up @@ -515,7 +526,8 @@ class PubkyRepo @Inject constructor(
/**
* Adopts the Ring identity [pubky]. [knownProfile] is read once sign-in completes, and a profile it returns that
* matches the signed-in key is used instead of resolving the profile again; it must return only a profile that was
* found, never the result of a failed or empty lookup.
* found, never the result of a failed or empty lookup. Like iOS, it ends the current [PubkySignIn] before it
* installs the adopted session, so an adoption that fails at sign-in ends it too.
*/
suspend fun adoptRingIdentity(
pubky: String,
Expand All @@ -535,12 +547,15 @@ class PubkyRepo @Inject constructor(
"Ring credential does not match '${redacted(pubky)}'"
}
keychain.upsertString(Keychain.Key.SHARED_PUBKY_SOURCE.name, reference)
// Before the SDK installs the session, which can be of the same identity: a contact save of the
// ending sign-in that the SDK runs after it then fails the sign-in check under the SDK lock.
signInGeneration.incrementAndGet()
signInOrSignUpAdoptedIdentity(secretKeyHex, rawPublicKey)
sessionInstalled = true

val prefixedPublicKey = rawPublicKey.ensurePubkyPrefix()
clearProfileIfIdentityChanged(prefixedPublicKey)
_publicKey.update { prefixedPublicKey }
startSignIn(prefixedPublicKey)
notifyBackupStateChanged()
Logger.info("Adopted ring identity for '${redacted(rawPublicKey)}'", context = TAG)
prefixedPublicKey
Expand Down Expand Up @@ -648,6 +663,18 @@ class PubkyRepo @Inject constructor(
}
}

/** The current sign-in, for work that must stop once it ends, or null when no identity is signed in. */
fun currentSignIn(): PubkySignIn? {
// The generation is read first, so a sign-out between the two reads leaves a sign-in that is already over.
val generation = signInGeneration.get()
val publicKey = _publicKey.value ?: return null
return PubkySignIn(publicKey, generation)
}

/** Whether [signIn] has not ended: its identity has not signed out and no other sign-in has started since. */
fun isCurrent(signIn: PubkySignIn): Boolean =
signInGeneration.get() == signIn.generation && _publicKey.value == signIn.publicKey
Comment thread
Jasonvdb marked this conversation as resolved.

// endregion

// region Profile loading
Expand Down Expand Up @@ -814,7 +841,7 @@ class PubkyRepo @Inject constructor(
tags = tags,
status = null,
)
_publicKey.update { publicKey }
startSignIn(publicKey)
setProfile(createdProfile)
cacheMetadata(createdProfile)
settingsStore.setPubkyProfileSetupPending(false)
Expand Down Expand Up @@ -1117,8 +1144,18 @@ class PubkyRepo @Inject constructor(
}
}

/**
* Saves a contact's label and keeps the rest of its profile as the contact's local override. The edit belongs to
* [signIn]: once that identity signs out or the next sign-in starts, it fails with
* [PubkyContactError.SignInChanged]. The SDK checks [signIn] under the lock it saves under, so an edit whose
* sign-in ended before the SDK runs its save writes nothing. When the sign-in ends after the SDK save, the label
* stays saved for that identity, but no override or contact row is written. Sign-out clears the overrides, so a
* save that lands after it must not write one back, least of all for the next identity, which may have saved a
* contact with the same key.
*/
@Suppress("LongParameterList")
suspend fun updateContact(
signIn: PubkySignIn,
publicKey: String,
name: String,
bio: String,
Expand All @@ -1137,15 +1174,29 @@ class PubkyRepo @Inject constructor(
tags = tags,
status = null,
)
pubkyService.saveContact(prefixedKey, name)
upsertContactProfileOverride(updatedProfile)
updateContacts { current ->
current.map { if (it.publicKey == prefixedKey) updatedProfile else it }
.sortedBy { it.name.lowercase() }
requireCurrent(signIn)
// The SDK checks the identity and the sign-in under the same lock as the save, so no session that another
// sign-in installs can slip in between, not even one of the same identity.
pubkyService.saveContact(
prefixedKey,
name,
expectedIdentity = signIn.publicKey,
isStillCurrent = { isCurrent(signIn) },
)
upsertContactProfileOverride(updatedProfile, signIn)
synchronized(contactsLock) {
requireCurrent(signIn)
updateContacts { current ->
current.map { if (it.publicKey == prefixedKey) updatedProfile else it }
.sortedBy { it.name.lowercase() }
}
}
markContactsLoaded()
Logger.info("Updated contact '${redacted(prefixedKey)}'", context = TAG)
}
}.recoverCatching {
if (it.hasCause(PubkyContactError.SignInChanged)) throw PubkyContactError.SignInChanged
throw it
}

suspend fun removeContact(publicKey: String): Result<Unit> = runSuspendCatching {
Expand All @@ -1158,7 +1209,7 @@ class PubkyRepo @Inject constructor(
Logger.info("Removed contact '${redacted(prefixedKey)}'", context = TAG)
}
}.recoverCatching {
if (it.containsActiveSubscriptionError()) throw PubkyContactError.ActiveSubscription
if (it.hasCause(PubkyContactError.ActiveSubscription)) throw PubkyContactError.ActiveSubscription
throw it
}

Expand Down Expand Up @@ -1372,7 +1423,7 @@ class PubkyRepo @Inject constructor(
}
}

_publicKey.update { publicKey }
startSignIn(publicKey)
var pendingSaved = false
try {
settingsStore.setPubkyProfileSetupPending(true)
Expand Down Expand Up @@ -1473,7 +1524,7 @@ class PubkyRepo @Inject constructor(
val secretKeyHex = deriveLocalSecretKeyFromWalletSeed()
keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex)
pubkyService.signIn(secretKeyHex)
_publicKey.update { pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix() }
startSignIn(pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix())
}

PubkySessionBackupKind.ExternalSession -> Unit
Expand Down Expand Up @@ -1507,7 +1558,7 @@ class PubkyRepo @Inject constructor(
val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()

notifyBackupStateChanged()
_publicKey.update { publicKey }
continueSignIn(publicKey)

true
}
Expand Down Expand Up @@ -1765,18 +1816,23 @@ class PubkyRepo @Inject constructor(
}.getOrNull()
?: listOf(PaykitReceiverPaths.WALLET)

private suspend fun upsertContactProfileOverride(profile: PubkyProfile) {
private suspend fun upsertContactProfileOverride(profile: PubkyProfile, signIn: PubkySignIn) {
val prefixedKey = profile.publicKey.ensurePubkyPrefix()
val ownerPublicKey = requireNotNull(_publicKey.value) { "Pubky identity unavailable" }
pubkyStore.update { data ->
// Checked as the store applies the write: sign-out ends the sign-in before it resets the store.
if (!isCurrent(signIn)) return@update data
data.copy(
ownerPublicKey = ownerPublicKey,
ownerPublicKey = signIn.publicKey,
contactProfileOverrides = data.contactProfileOverrides + (prefixedKey to profile.toProfileData()),
)
}
notifyBackupStateChanged()
}

private fun requireCurrent(signIn: PubkySignIn) {
if (!isCurrent(signIn)) throw PubkyContactError.SignInChanged
}

private suspend fun removeContactProfileOverride(publicKey: String) {
val prefixedKey = publicKey.ensurePubkyPrefix()
val ownerPublicKey = requireNotNull(_publicKey.value) { "Pubky identity unavailable" }
Expand Down Expand Up @@ -1860,10 +1916,23 @@ class PubkyRepo @Inject constructor(
_profile.update { profile }
}

private fun startSignIn(publicKey: String) {
// First, so the sign-in it replaces has ended before the key is published, even when it is the same identity.
signInGeneration.incrementAndGet()
_publicKey.update { publicKey }
}

private fun continueSignIn(publicKey: String) {
// Restoring or refreshing the session already signed in keeps its sign-in, so an edit under way still saves.
if (_publicKey.value != publicKey) startSignIn(publicKey)
}

private suspend fun clearAuthenticatedState(
clearCachedProfile: Boolean = true,
clearRestorationFailure: Boolean = true,
) = withContext(ioDispatcher) {
// First, so work of the ending sign-in stops before the store reset below, and cannot write after it.
signInGeneration.incrementAndGet()
if (clearCachedProfile) {
evictPubkyImages()
profileWriteGeneration.incrementAndGet()
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -613,12 +613,19 @@ class PaykitSdkService @Inject constructor(
}
}

/**
* Saves the contact [publicKey]. [expectedIdentity] and [isStillCurrent] are checked under the lock the save runs
* under, right before it, so no session can be installed between the checks and the write. A save whose
* [isStillCurrent] returns false writes nothing and fails with [PubkyContactError.SignInChanged].
*/
@Suppress("LongParameterList")
suspend fun saveContact(
publicKey: String,
label: String?,
receiverPaths: List<String>? = null,
restorePrivateConnection: Boolean = false,
expectedIdentity: String? = null,
isStillCurrent: (() -> Boolean)? = null,
): ContactRecord {
isSetup.await()
return operationLock.withLock {
Expand All @@ -628,6 +635,7 @@ class PaykitSdkService @Inject constructor(
"Paykit identity changed before saving the contact"
}
}
if (isStillCurrent?.invoke() == false) throw PubkyContactError.SignInChanged
val existing = handle.contactRecord(publicKey)
check(restorePrivateConnection || existing != null) { "Contact no longer exists" }
val existingPaths = existing?.receiverPaths.orEmpty()
Expand Down
11 changes: 10 additions & 1 deletion app/src/main/java/to/bitkit/services/PubkyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,23 @@ class PubkyService @Inject constructor(
paykitSdkService.contactRecords()
}

@Suppress("LongParameterList")
suspend fun saveContact(
publicKey: String,
label: String?,
receiverPaths: List<String>? = null,
restorePrivateConnection: Boolean = false,
expectedIdentity: String? = null,
isStillCurrent: (() -> Boolean)? = null,
): ContactRecord = ServiceQueue.CORE.background {
paykitSdkService.saveContact(publicKey, label, receiverPaths, restorePrivateConnection, expectedIdentity)
paykitSdkService.saveContact(
publicKey,
label,
receiverPaths,
restorePrivateConnection,
expectedIdentity,
isStillCurrent,
)
}

suspend fun removeContact(publicKey: String): ContactRecord? = ServiceQueue.CORE.background {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import to.bitkit.repositories.PrivatePaykitPaymentContext
import to.bitkit.repositories.PrivatePaykitRepo
import to.bitkit.repositories.PubkyContactError
import to.bitkit.repositories.PubkyRepo
import to.bitkit.repositories.PubkySignIn
import to.bitkit.repositories.PublicPaykitPaymentResult
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.utils.Logger
Expand Down Expand Up @@ -347,10 +348,13 @@ class ContactDetailViewModel @Inject constructor(
transform: (ImmutableList<String>) -> ImmutableList<String>,
onSuccess: () -> Unit = {},
) {
val signIn = pubkyRepo.currentSignIn() ?: return
viewModelScope.launch {
tagPersistenceMutex.withLock {
contactLoad?.join()
if (!isTagChangeCurrent(signIn)) return@withLock
pubkyRepo.resolvePendingContactProfile(publicKey)
if (!isTagChangeCurrent(signIn)) return@withLock
val state = _uiState.value
val profile = pubkyRepo.contacts.value.find { it.publicKey == publicKey }
?: state.profile
Expand All @@ -361,6 +365,7 @@ class ContactDetailViewModel @Inject constructor(
return@withLock
}
pubkyRepo.updateContact(
signIn = signIn,
publicKey = publicKey,
name = profile.name,
bio = profile.bio,
Expand All @@ -376,6 +381,7 @@ class ContactDetailViewModel @Inject constructor(
}
onSuccess()
}.onFailure {
if (!isTagChangeCurrent(signIn)) return@onFailure
Logger.error("Failed to update tags for contact '$redactedPublicKey'", it, context = TAG)
ToastEventBus.send(
type = Toast.ToastType.ERROR,
Expand All @@ -386,6 +392,14 @@ class ContactDetailViewModel @Inject constructor(
}
}
}

private fun isTagChangeCurrent(signIn: PubkySignIn): Boolean {
val isCurrent = pubkyRepo.isCurrent(signIn)
if (!isCurrent) {
Logger.info("Dropped a tag change for '$redactedPublicKey' after the Pubky sign-in ended", context = TAG)
}
return isCurrent
}
}

@Stable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,14 @@ class EditContactViewModel @Inject constructor(
_uiState.update { it.copy(showDeleteDialog = false) }
}

/** The save belongs to the Pubky sign-in it was made in, and stops quietly once that sign-in has ended. */
fun save() {
val state = _uiState.value
val signIn = pubkyRepo.currentSignIn() ?: return
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true) }
pubkyRepo.updateContact(
signIn = signIn,
publicKey = publicKey,
name = state.name,
bio = state.bio,
Expand All @@ -223,7 +226,11 @@ class EditContactViewModel @Inject constructor(
)
_effects.emit(EditContactEffect.SaveSuccess)
}.onFailure {
Logger.error("Failed to save contact '$publicKey'", it, context = TAG)
if (pubkyRepo.isCurrent(signIn)) {
Logger.error("Failed to save contact '$publicKey'", it, context = TAG)
} else {
Logger.info("Dropped a contact edit after the Pubky sign-in ended", context = TAG)
}
_uiState.update { it.copy(isSaving = false) }
}
}
Expand Down
Loading
Loading