Skip to content
Merged
109 changes: 82 additions & 27 deletions app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,22 @@ class PrivatePaykitRepo @Inject constructor(
private val activeLinkPreparations = mutableMapOf<String, CompletableDeferred<LinkedPeerState?>>()
private var preparationJob: Job? = null
private var preparationGeneration = 0
private var isDeletingProfile = false
private var pendingForceRefreshLightning = false
private val _isProfileDeletionInProgress = MutableStateFlow(false)
val isProfileDeletionInProgress = _isProfileDeletionInProgress.asStateFlow()
private val isDeletingProfile: Boolean
get() = _isProfileDeletionInProgress.value
private var pendingLightningRefresh: LightningRefresh? = null
private val unavailableLinkRetryAt = mutableMapOf<String, KotlinInstant>()
private var state: PrivatePaykitState? = null
private val pendingMessageDrainRetries = mutableMapOf<String, PrivateMessageDrainRetry>()
private var pendingMessageDrainRead: PrivateMessageDrainRead? = null

private class LightningRefresh(
val owner: PrivatePaykitState,
val remainingKeys: MutableSet<String>,
val isStillCurrent: (() -> Boolean)?,
)

private class PrivateMessageDrainRead(val identity: String, val generation: Int) {
val retries = mutableSetOf<PrivateMessageDrainRetry>()
lateinit var result: Deferred<PrivateMessageDrainState?>
Expand Down Expand Up @@ -222,9 +231,10 @@ class PrivatePaykitRepo @Inject constructor(
requireImmediatePublication: Boolean = false,
): Result<Unit> = withContext(serializedDispatcher) {
runSuspendCatching {
val generation = preparationGeneration
val keys = rememberSavedContacts(publicKeys, replacing = true)
if (!canPublishPrivateEndpoints()) {
prepareRelevantPrivateLinksIfAvailable(keys, "prepare")
prepareRelevantPrivateLinksIfAvailable(keys, "prepare", generation)
if (requireImmediatePublication && keys.isNotEmpty()) throw PrivatePaykitError.PrivateUnavailable
return@runSuspendCatching
}
Expand Down Expand Up @@ -302,17 +312,14 @@ class PrivatePaykitRepo @Inject constructor(
pendingPreparationKeys.addAll(
publicKeys.filter { requeueActive || forceRefreshLightning || it !in activePreparationKeys }
)
pendingForceRefreshLightning = pendingForceRefreshLightning || forceRefreshLightning
if (preparationJob?.isActive == true || pendingPreparationKeys.isEmpty()) return
preparationJob = retryScope.launch {
try {
while (pendingPreparationKeys.isNotEmpty()) {
awaitContactPreparationActive(Priority.Background)
if (pendingPreparationKeys.isEmpty()) break
val keys = pendingPreparationKeys.intersect(knownSavedContactKeys)
val forceRefresh = pendingForceRefreshLightning
pendingPreparationKeys.clear()
pendingForceRefreshLightning = false
activePreparationKeys = keys
val generation = preparationGeneration
runSuspendCatching {
Expand All @@ -323,11 +330,15 @@ class PrivatePaykitRepo @Inject constructor(
publishLocalEndpoints(
keys,
"contact preparation",
forceRefresh,
priority = Priority.Background,
).getOrThrow()
} else {
prepareRelevantPrivateLinksIfAvailable(keys, "contact preparation", Priority.Background)
prepareRelevantPrivateLinksIfAvailable(
keys,
"contact preparation",
generation,
Priority.Background,
)
}
}.onFailure {
Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG)
Expand All @@ -345,18 +356,17 @@ class PrivatePaykitRepo @Inject constructor(
preparationGeneration += 1
pendingPreparationKeys.clear()
activePreparationKeys = emptySet()
pendingForceRefreshLightning = false
activeLinkPreparations.clear()
clearPendingMessageDrainRetries()
}

suspend fun beginProfileDeletion() = withContext(serializedDispatcher) {
isDeletingProfile = true
_isProfileDeletionInProgress.update { true }
invalidateContactPreparation()
}

suspend fun endProfileDeletion() = withContext(serializedDispatcher) {
isDeletingProfile = false
_isProfileDeletionInProgress.update { false }
}

suspend fun refreshSavedContactEndpoints(
Expand Down Expand Up @@ -384,8 +394,15 @@ class PrivatePaykitRepo @Inject constructor(
reason: String,
forceRefreshLightning: Boolean = false,
requeueActive: Boolean = false,
isStillCurrent: (() -> Boolean)? = null,
): Result<Unit> = withContext(serializedDispatcher) {
runSuspendCatching {
if (isStillCurrent?.invoke() == false) return@runSuspendCatching
if (forceRefreshLightning) {
val owner = ensureState()
if (isStillCurrent?.invoke() == false) return@runSuspendCatching
pendingLightningRefresh = LightningRefresh(owner, knownSavedContactKeys.toMutableSet(), isStillCurrent)
Comment thread
ben-kaufman marked this conversation as resolved.
}
scheduleContactPreparation(knownSavedContactKeys.toList(), forceRefreshLightning, requeueActive)
}.onFailure {
Logger.warn("Failed to refresh private Paykit endpoints for '$reason'", it, context = TAG)
Expand Down Expand Up @@ -495,6 +512,7 @@ class PrivatePaykitRepo @Inject constructor(

suspend fun closeAndClear(): Result<Unit> = withContext(serializedDispatcher) {
runSuspendCatching {
pendingLightningRefresh = null
invalidateContactPreparation()
publicationMutex.withLock {
clearPendingMessageDrainRetries()
Expand Down Expand Up @@ -719,6 +737,7 @@ class PrivatePaykitRepo @Inject constructor(
runSuspendCatching {
val decoded = backup?.let { json.decodeFromString<PrivatePaykitBackup>(it) }
decoded?.let { paykitSdkService.retainRecoveryBackup(it.sdkState) }
pendingLightningRefresh = null
invalidateContactPreparation()
state = PrivatePaykitState()
knownSavedContactKeys.clear()
Expand Down Expand Up @@ -964,7 +983,6 @@ class PrivatePaykitRepo @Inject constructor(
private suspend fun publishLocalEndpoints(
publicKeys: Collection<String>,
reason: String,
forceRefreshLightning: Boolean = false,
requireImmediatePublication: Boolean = false,
priority: Priority = Priority.Ordered,
): Result<Unit> = withContext(serializedDispatcher) {
Expand Down Expand Up @@ -996,13 +1014,15 @@ class PrivatePaykitRepo @Inject constructor(
return@withLock
}

val lightningRefresh = currentLightningRefresh()
val publication = preparePrivatePaymentListReservations(
links.publicKeys,
reason,
forceRefreshLightning,
generation,
forceRefreshLightningKeys = lightningRefresh?.remainingKeys.orEmpty(),
generation = generation,
)
if (!isCurrentPublication(generation, identity, requireImmediatePublication)) return@withLock
if (lightningRefresh?.isStillCurrent?.invoke() == false) return@withLock
if (publication.updates.isEmpty()) {
schedulePendingPrivateMessageDrainRetries(reason, links.linkRetryKeys, identity = identity)
if (requireImmediatePublication) publication.firstError?.let { throw it }
Expand All @@ -1018,17 +1038,43 @@ class PrivatePaykitRepo @Inject constructor(
val retryKeys = (links.linkRetryKeys + privatePaymentListDeliveryRetryKeys(report)).distinct()
schedulePendingPrivateMessageDrainRetries(reason, retryKeys, identity = identity)

if (firstError != null) {
if (requireImmediatePublication) throw firstError
Logger.warn("Deferred private endpoint publication during '$reason'", firstError, context = TAG)
}
acknowledgeLightningRefresh(lightningRefresh, publication.updates, report, generation)
handlePrivatePublicationError(firstError, reason, requireImmediatePublication)
}
}.onFailure {
schedulePreparedLinkRetries(links.linkRetryKeys, reason, generation, identity)
}.getOrThrow()
}
}

private fun handlePrivatePublicationError(error: Throwable?, reason: String, requireImmediatePublication: Boolean) {
if (error == null) return
if (requireImmediatePublication) throw error
Logger.warn("Deferred private endpoint publication during '$reason'", error, context = TAG)
}

private fun currentLightningRefresh(): LightningRefresh? = pendingLightningRefresh?.takeIf {
it.owner === state && it.isStillCurrent?.invoke() != false
}

private fun acknowledgeLightningRefresh(
refresh: LightningRefresh?,
updates: List<PrivatePaymentListReservationUpdateInput>,
report: PrivatePaymentListDeliveryReport,
generation: Int,
) {
if (generation != preparationGeneration || refresh == null || pendingLightningRefresh !== refresh) return
if (refresh.owner !== state || refresh.isStillCurrent?.invoke() == false) return
val refreshedKeys = updates.filter { update ->
update.reservations.any { it.identifier == MethodId.Bolt11.rawValue }
}.map { it.counterparty }.toSet()
val queuedKeys = report.queued.mapNotNull { normalizedPublicKey(it.counterparty) }.toSet()
refresh.remainingKeys.removeAll(refreshedKeys.intersect(queuedKeys))
if (refresh.remainingKeys.isEmpty()) {
pendingLightningRefresh = null
}
}

private suspend fun schedulePreparedLinkRetries(
keys: Collection<String>,
reason: String,
Expand All @@ -1054,24 +1100,24 @@ class PrivatePaykitRepo @Inject constructor(
requireImmediatePublication: Boolean,
status: IdentityStatus? = null,
): Boolean {
val isCurrent = generation == preparationGeneration &&
(status?.publicKey ?: pubkyService.currentPublicKey()) == identity
val currentIdentity = status?.publicKey ?: pubkyService.currentPublicKey()
val isCurrent = !isDeletingProfile && generation == preparationGeneration && currentIdentity == identity
if (!isCurrent && requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable
return isCurrent
}

private suspend fun preparePrivatePaymentListReservations(
publicKeys: Collection<String>,
reason: String,
forceRefreshLightning: Boolean,
forceRefreshLightningKeys: Set<String>,
generation: Int,
): PrivatePublicationPreparation {
var firstError: Throwable? = null
val updates = mutableListOf<PrivatePaymentListReservationUpdateInput>()
for (publicKey in publicKeys) {
if (generation != preparationGeneration) break
if (publicKey in knownSavedContactKeys) {
runSuspendCatching { privatePaymentListUpdate(publicKey, forceRefreshLightning) }
runSuspendCatching { privatePaymentListUpdate(publicKey, publicKey in forceRefreshLightningKeys) }
.onSuccess { updates += it }
.onFailure {
firstError = firstError ?: it
Expand Down Expand Up @@ -1182,24 +1228,29 @@ class PrivatePaykitRepo @Inject constructor(
private suspend fun prepareRelevantPrivateLinksIfAvailable(
publicKeys: Collection<String>,
reason: String,
generation: Int,
priority: Priority = Priority.Ordered,
) {
if (isDeletingProfile || generation != preparationGeneration) return
if (isContactSharingCleanupPending() || !hasPrivatePaymentAccessForCurrentProfile()) return
drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct(), priority)
if (isDeletingProfile || generation != preparationGeneration) return
drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct(), generation, priority)
}

private suspend fun drainAndSchedulePrivateLinkRetries(
reason: String,
retryKeys: Collection<String>,
generation: Int,
priority: Priority = Priority.Ordered,
) {
val newKeys = retryKeys.toSet() - pendingMessageDrainRetries.keys
val pendingKeys = pendingPrivateMessageDrainKeys(newKeys, retryMissingPeers = true, priority = priority)
if (pendingKeys.isEmpty()) return
if (pendingKeys.isEmpty() || isDeletingProfile || generation != preparationGeneration) return

drainPendingPrivateMessages(reason, retryKeys = pendingKeys, priority = priority)
if (isDeletingProfile || generation != preparationGeneration) return
val pendingRetryKeys = pendingPrivateMessageDrainKeys(pendingKeys, priority = priority)
if (pendingRetryKeys.isNotEmpty()) {
if (pendingRetryKeys.isNotEmpty() && !isDeletingProfile && generation == preparationGeneration) {
schedulePendingPrivateMessageDrainRetries(reason, retryKeys = pendingRetryKeys)
}
}
Expand Down Expand Up @@ -2073,18 +2124,20 @@ class PrivatePaykitRepo @Inject constructor(
}

private suspend fun canPublishPrivateEndpoints(status: IdentityStatus? = null): Boolean {
if (isDeletingProfile) return false
val settings = settingsStore.data.first()
val locallyEligible = settings.sharesPrivatePaykitEndpoints &&
!isContactSharingCleanupPending() &&
App.currentActivity?.value != null &&
walletRepo.walletExists() &&
lightningRepo.lightningState.value.nodeLifecycleState.isRunning()
if (!locallyEligible) return false
return if (status?.publicKey != null) {
if (!locallyEligible || isDeletingProfile) return false
val hasAccess = if (status?.publicKey != null) {
status.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE
} else {
hasPrivatePaymentAccessForCurrentProfile()
}
return hasAccess && !isDeletingProfile
}

private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching {
Expand Down Expand Up @@ -2216,6 +2269,8 @@ class PrivatePaykitRepo @Inject constructor(

private fun rememberSavedContacts(publicKeys: Collection<String>, replacing: Boolean): List<String> {
val normalizedKeys = publicKeys.mapNotNull { normalizedPublicKey(it) }.distinct()
val newKeys = normalizedKeys.filter { it !in knownSavedContactKeys }
currentLightningRefresh()?.remainingKeys?.addAll(newKeys)
if (replacing) {
knownSavedContactKeys.clear()
unavailableLinkRetryAt.keys.retainAll(normalizedKeys.toSet())
Expand Down
20 changes: 18 additions & 2 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ class PubkyRepo @Inject constructor(
private val adoptionMutex = Mutex()
private val profileWriteGeneration = AtomicLong(0L)
private val signInGeneration = AtomicLong(0L)
private val storedIdentityRevision = AtomicLong(0L)
private val readOnlyProfileGeneration = AtomicLong(0L)
private val readOnlyProfileLock = Any()
private var isServiceInitialized = false
Expand Down Expand Up @@ -482,6 +483,7 @@ class PubkyRepo @Inject constructor(
clearAuthenticatedState(
clearCachedProfile = false,
clearRestorationFailure = notifyFailure,
preserveStoredIdentity = true,
)
if (notifyFailure) _sessionRestorationFailed.update { result == InitResult.RestorationFailed }
}
Expand Down Expand Up @@ -640,6 +642,7 @@ class PubkyRepo @Inject constructor(
// 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()
storedIdentityRevision.incrementAndGet()
signInOrSignUpAdoptedIdentity(secretKeyHex, rawPublicKey)
sessionInstalled = true

Expand Down Expand Up @@ -765,6 +768,16 @@ class PubkyRepo @Inject constructor(
fun isCurrent(signIn: PubkySignIn): Boolean =
signInGeneration.get() == signIn.generation && _publicKey.value == signIn.publicKey

internal suspend fun savedIdentityGuard(): (() -> Boolean)? {
val revision = storedIdentityRevision.get()
return withContext(ioDispatcher) {
if (!runSuspendCatching { hasIdentity() }.getOrDefault(false) || storedIdentityRevision.get() != revision) {
return@withContext null
}
return@withContext { storedIdentityRevision.get() == revision }
}
}

// endregion

// region Profile loading
Expand Down Expand Up @@ -2094,16 +2107,17 @@ class PubkyRepo @Inject constructor(
_profile.update { profile }
}

private fun startSignIn(publicKey: String) {
private fun startSignIn(publicKey: String, restoringStoredIdentity: Boolean = false) {
// First, so the sign-in it replaces has ended before the key is published, even when it is the same identity.
signInGeneration.incrementAndGet()
if (!restoringStoredIdentity) storedIdentityRevision.incrementAndGet()
invalidateReadOnlyProfile(keepingPublicKey = publicKey)
_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)
if (_publicKey.value != publicKey) startSignIn(publicKey, restoringStoredIdentity = true)
}

private fun invalidateReadOnlyProfile(keepingPublicKey: String? = null) = synchronized(readOnlyProfileLock) {
Expand All @@ -2114,9 +2128,11 @@ class PubkyRepo @Inject constructor(
private suspend fun clearAuthenticatedState(
clearCachedProfile: Boolean = true,
clearRestorationFailure: Boolean = true,
preserveStoredIdentity: Boolean = false,
) = withContext(ioDispatcher) {
// First, so work of the ending sign-in stops before the store reset below, and cannot write after it.
signInGeneration.incrementAndGet()
if (!preserveStoredIdentity) storedIdentityRevision.incrementAndGet()
if (clearCachedProfile) {
invalidateReadOnlyProfile()
evictPubkyImages()
Expand Down
Loading
Loading