From d18939e0ed099f1930523c559ccb11a7bc2cb6bc Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 18:17:46 +0200 Subject: [PATCH 01/79] fix: stop public pubky reads queueing behind the sdk lock Profile, avatar and follows lookups are unauthenticated public reads, so they now run through a bounded, cancellable limiter (six at a time) instead of the Paykit SDK operation lock, taking the lock only when no SDK exists yet. Sign-in, sign-up and session import start the identity republish in the background rather than holding the lock while it waits on the DHT. --- Bitkit/Services/PubkyService.swift | 104 ++++++++-- BitkitTests/PaykitSdkReadLimiterTests.swift | 190 ++++++++++++++++++ BitkitTests/PubkyIdentityRepublishTests.swift | 21 ++ 3 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 BitkitTests/PaykitSdkReadLimiterTests.swift diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 105479454..a3bad4cbc 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -322,6 +322,7 @@ actor PaykitSdkService { private let sessionProvider = PaykitSdkSessionProvider() private let paymentAdapter = PaykitSdkPaymentAdapter() private let operationLock = PaykitSdkOperationLock() + private let publicReadLimiter = PaykitSdkReadLimiter(maxConcurrent: 6) private let pubkyClientConfig = PaykitSdkService.makePubkyClientConfig(localTestnetHost: Env.pubkyLocalTestnetHost) private let bootstrapFactory: BootstrapFactory private var cachedBootstrap: PubkySessionBootstrap? @@ -337,7 +338,7 @@ actor PaykitSdkService { } func initialize() async throws { - Task { await republishIdentityIfNeeded() } + startIdentityRepublish() try await operationLock.withLock { var sdk = try handle() do { @@ -361,6 +362,12 @@ actor PaykitSdkService { } } + /// Republishes without making the caller wait on the DHT. Only the public key string reaches the task, so it never + /// keeps session access alive. + func startIdentityRepublish(publicKey: String? = nil) { + Task { await republishIdentityIfNeeded(publicKey: publicKey) } + } + func republishIdentityIfNeeded(publicKey: String? = nil, now: Date = Date(), timeout: Duration = .seconds(5)) async { guard !Task.isCancelled else { return } // Swift FFI may ignore cancellation; keep the in-flight guard until publication actually finishes. @@ -547,12 +554,10 @@ actor PaykitSdkService { } func fetchFile(uri: String, maxBytes: UInt64) async throws -> Data { - try await operationLock.withLock { - guard let data = try await handle().fetchPubkyFileBounded(uri: uri, maxBytes: maxBytes) else { - throw PubkyServiceError.profileNotFound - } - return data + guard let data = try await withPublicRead({ try await $0.fetchPubkyFileBounded(uri: uri, maxBytes: maxBytes) }) else { + throw PubkyServiceError.profileNotFound } + return data } func publishPaykitProfile(_ profile: Paykit.PaykitProfile) async throws -> Paykit.PaykitProfileRecord { @@ -581,9 +586,7 @@ actor PaykitSdkService { } func fetchPubkyFollows(publicKey: String) async throws -> [String] { - try await operationLock.withLock { - try await handle().fetchPubkyFollows(publicKey: publicKey) - } + try await withPublicRead { try await $0.fetchPubkyFollows(publicKey: publicKey) } } func contactRecords() async throws -> [Paykit.ContactRecord] { @@ -613,8 +616,8 @@ actor PaykitSdkService { } func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ContactProfileResolution? { - try await operationLock.withLock { - try await handle().resolveContactProfile( + try await withPublicRead { + try await $0.resolveContactProfile( publicKey: publicKey, receiverPath: PaykitReceiverPath.wallet, allowPubkyProfileFallback: allowPubkyProfileFallback @@ -973,6 +976,21 @@ actor PaykitSdkService { return created } + /// Public-only read lane: skips `operationLock` and runs up to the limiter's cap at once. It is sound only for reads + /// that fetch unauthenticated public Pubky data; in paykit rc56 those use the SDK's public client and never load + /// session access, the local secret or the state blob. Session, secret or state-blob operations must never use it. + /// Without an SDK it takes the locked path, so a read never builds one outside the lock. + private func withPublicRead(_ read: (PaykitSdk) async throws -> T) async throws -> T { + if sdk != nil { + let result: T? = try await publicReadLimiter.withSlot { + guard let sdk else { return nil } + return try await read(sdk) + } + if let result { return result } + } + return try await operationLock.withLock { try await read(handle()) } + } + private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { try await operationLock.withLock { let sdk = try handle() @@ -1043,7 +1061,7 @@ actor PaykitSdkService { let sdk = try handle() _ = try await sdk.initialize() await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) - await republishIdentityIfNeeded(publicKey: result.publicKey) + startIdentityRepublish(publicKey: result.publicKey) } private func publishReceiverMarkerIfLiveSessionAvailable(using sdk: PaykitSdk) async { @@ -1240,6 +1258,68 @@ final class PaykitSdkOperationLock: @unchecked Sendable { } } +/// Caps concurrent operations. Waiters are served in FIFO order and leave the queue as soon as their task is +/// cancelled, without taking a slot, so an abandoned read never runs ahead of work queued after it. +final class PaykitSdkReadLimiter: @unchecked Sendable { + private let lock = NSLock() + private let maxConcurrent: Int + private var inFlight = 0 + private var waiters: [(id: UUID, continuation: CheckedContinuation)] = [] + + init(maxConcurrent: Int) { + self.maxConcurrent = maxConcurrent + } + + func withSlot(_ operation: () async throws -> T) async throws -> T { + try await acquire() + defer { release() } + try Task.checkCancellation() + return try await operation() + } + + private func acquire() async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + lock.lock() + if Task.isCancelled { + lock.unlock() + continuation.resume(throwing: CancellationError()) + } else if inFlight < maxConcurrent { + inFlight += 1 + lock.unlock() + continuation.resume() + } else { + waiters.append((id, continuation)) + lock.unlock() + } + } + } onCancel: { + removeWaiter(id: id)?.resume(throwing: CancellationError()) + } + } + + private func removeWaiter(id: UUID) -> CheckedContinuation? { + lock.lock() + defer { lock.unlock() } + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return nil } + return waiters.remove(at: index).continuation + } + + /// Hands the slot straight to the oldest waiter, so a newcomer cannot overtake the queue. + private func release() { + lock.lock() + guard !waiters.isEmpty else { + inFlight -= 1 + lock.unlock() + return + } + let next = waiters.removeFirst() + lock.unlock() + next.continuation.resume() + } +} + extension PublicPaykitService.Endpoint { var paykitPublicReceivingDetail: PublicReceivingDetail { PublicReceivingDetail( diff --git a/BitkitTests/PaykitSdkReadLimiterTests.swift b/BitkitTests/PaykitSdkReadLimiterTests.swift new file mode 100644 index 000000000..e9ab545ab --- /dev/null +++ b/BitkitTests/PaykitSdkReadLimiterTests.swift @@ -0,0 +1,190 @@ +@testable import Bitkit +import XCTest + +final class PaykitSdkReadLimiterTests: XCTestCase { + private actor Recorder { + private(set) var events: [String] = [] + + func record(_ event: String) { + events.append(event) + } + } + + private struct ReadFailure: Error {} + + private struct Gate { + private let signal = AsyncStream.makeStream() + + func wait() async { + for await _ in signal.stream {} + } + + func open() { + signal.continuation.finish() + } + } + + /// Starts a read that records `name` once it holds a slot, then keeps the slot until `gate` opens. + private func startGatedRead( + _ name: String, + on limiter: PaykitSdkReadLimiter, + recorder: Recorder, + gate: Gate + ) -> Task { + Task { + try await limiter.withSlot { + await recorder.record(name) + await gate.wait() + } + } + } + + private func waitForEvents(_ recorder: Recorder, count: Int) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while await recorder.events.count < count, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(5)) + } + } + + func testConcurrentReadsNeverExceedTheCap() async throws { + let limiter = PaykitSdkReadLimiter(maxConcurrent: 2) + let recorder = Recorder() + let gates = [Gate(), Gate(), Gate()] + + let first = startGatedRead("first", on: limiter, recorder: recorder, gate: gates[0]) + let second = startGatedRead("second", on: limiter, recorder: recorder, gate: gates[1]) + try await waitForEvents(recorder, count: 2) + let third = startGatedRead("third", on: limiter, recorder: recorder, gate: gates[2]) + try await Task.sleep(for: .milliseconds(50)) + + let eventsAtCap = await recorder.events + XCTAssertEqual(Set(eventsAtCap), ["first", "second"]) + + gates[0].open() + try await first.value + try await waitForEvents(recorder, count: 3) + let eventsAfterRelease = await recorder.events + XCTAssertEqual(eventsAfterRelease.last, "third") + + gates[1].open() + gates[2].open() + try await second.value + try await third.value + } + + func testQueuedReadsRunInArrivalOrder() async throws { + let limiter = PaykitSdkReadLimiter(maxConcurrent: 1) + let recorder = Recorder() + let holderGate = Gate() + let holder = startGatedRead("holder", on: limiter, recorder: recorder, gate: holderGate) + try await waitForEvents(recorder, count: 1) + + var queued: [Task] = [] + for name in ["a", "b", "c"] { + queued.append(Task { + try await limiter.withSlot { + await recorder.record(name) + } + }) + try await Task.sleep(for: .milliseconds(50)) + } + + holderGate.open() + try await holder.value + for task in queued { + try await task.value + } + let events = await recorder.events + XCTAssertEqual(events, ["holder", "a", "b", "c"]) + } + + func testCancelledQueuedReadLeavesQueueWithoutTakingASlot() async throws { + let limiter = PaykitSdkReadLimiter(maxConcurrent: 1) + let recorder = Recorder() + let holderGate = Gate() + let holder = startGatedRead("holder", on: limiter, recorder: recorder, gate: holderGate) + try await waitForEvents(recorder, count: 1) + + let lookup = Task { + try await limiter.withSlot { + await recorder.record("lookup") + } + } + try await Task.sleep(for: .milliseconds(50)) + let payment = Task { + try await limiter.withSlot { + await recorder.record("payment") + } + } + try await Task.sleep(for: .milliseconds(50)) + + lookup.cancel() + do { + try await lookup.value + XCTFail("Expected the cancelled lookup to throw while the slot is still held") + } catch is CancellationError {} + + holderGate.open() + try await holder.value + try await payment.value + try await limiter.withSlot { + await recorder.record("after") + } + let events = await recorder.events + XCTAssertEqual(events, ["holder", "payment", "after"]) + } + + func testAlreadyCancelledReadDoesNotRunOrTakeASlot() async throws { + let limiter = PaykitSdkReadLimiter(maxConcurrent: 1) + let recorder = Recorder() + + let lookup = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await limiter.withSlot { + await recorder.record("lookup") + } + } + + do { + try await lookup.value + XCTFail("Expected the cancelled lookup to throw") + } catch is CancellationError {} + try await limiter.withSlot { + await recorder.record("payment") + } + let events = await recorder.events + XCTAssertEqual(events, ["payment"]) + } + + func testFailingReadReleasesItsSlotToTheNextWaiter() async throws { + let limiter = PaykitSdkReadLimiter(maxConcurrent: 1) + let recorder = Recorder() + let holderGate = Gate() + let failing = Task { + try await limiter.withSlot { + await recorder.record("failing") + await holderGate.wait() + throw ReadFailure() + } + } + try await waitForEvents(recorder, count: 1) + let next = Task { + try await limiter.withSlot { + await recorder.record("next") + } + } + try await Task.sleep(for: .milliseconds(50)) + + holderGate.open() + do { + try await failing.value + XCTFail("Expected the failing read to throw") + } catch is ReadFailure {} + try await next.value + try await limiter.withSlot { + await recorder.record("after") + } + let events = await recorder.events + XCTAssertEqual(events, ["failing", "next", "after"]) + } +} diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift index 597b12c01..beaa571a2 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -109,6 +109,27 @@ final class PubkyIdentityRepublishTests: XCTestCase { XCTAssertEqual(bootstrap.publicKeys.count, 2) } + func testStartedRepublishReturnsWhilePublicationIsStillRunning() async { + let started = expectation(description: "Publication started") + let finished = expectation(description: "Publication finished") + let gate = AsyncStream.makeStream() + let bootstrap = RepublishBootstrap(noPointer: .init()) + bootstrap.operation = { _ in + started.fulfill() + for await _ in gate.stream {} + finished.fulfill() + return true + } + let service = PaykitSdkService { _, _ in bootstrap } + + await service.startIdentityRepublish(publicKey: publicKey) + await fulfillment(of: [started], timeout: 1) + XCTAssertEqual(bootstrap.publicKeys, ["pubky\(publicKey)"]) + + gate.continuation.finish() + await fulfillment(of: [finished], timeout: 1) + } + func testAuthRepublishesSigningIdentityBeforeApprovalEvenWhenPublicationFails() async throws { for kind in [Approval.ordinary, .companion] { for result in [Result.success(true), .failure(PubkyServiceError.profileNotFound)] { From aa058ba6edb09cedd727245a091c4a8dde9cc50b Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 18:40:53 +0200 Subject: [PATCH 02/79] fix: keep pubky ring row profiles and reuse them on adopt The choice screen now keeps found Ring profiles for the session and remembers misses until the app returns to the foreground, dedupes lookups in flight, and runs one cancellable load that stops when the screen goes away or an adopt starts. Adopting reuses the row's profile for that key instead of fetching it again, shows a spinner on the tapped row and keeps the other rows disabled. A profile load that started before a save, adopt or sign-out no longer overwrites the newer profile. --- Bitkit/Components/PubkyChoiceRow.swift | 5 +- Bitkit/Managers/PubkyProfileManager.swift | 136 +++++++++- Bitkit/Views/Profile/PubkyChoiceView.swift | 54 ++-- BitkitTests/PubkyProfileManagerTests.swift | 275 +++++++++++++++++++++ 4 files changed, 436 insertions(+), 34 deletions(-) diff --git a/Bitkit/Components/PubkyChoiceRow.swift b/Bitkit/Components/PubkyChoiceRow.swift index b3fcaa242..4054a653d 100644 --- a/Bitkit/Components/PubkyChoiceRow.swift +++ b/Bitkit/Components/PubkyChoiceRow.swift @@ -8,6 +8,7 @@ struct PubkyChoiceRow: View { let title: String var avatarName: String? var avatarImageUrl: String? + var isLoading = false let accessibilityId: String let action: () -> Void @@ -39,7 +40,9 @@ struct PubkyChoiceRow: View { private var iconView: some View { Group { - if let icon { + if isLoading { + ActivityIndicator(size: 20) + } else if let icon { Image(icon) .resizable() .scaledToFit() diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 9f883b75a..ea040a3f8 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -31,6 +31,8 @@ class PubkyProfileManager: ObservableObject { case restorationFailed } + typealias RemoteProfileResolver = @Sendable (String) async throws -> PubkyProfile + @Published var authState: PubkyAuthState = .idle @Published var profile: PubkyProfile? @Published var publicKey: String? @@ -42,10 +44,20 @@ class PubkyProfileManager: ObservableObject { @Published private(set) var cachedName: String? @Published private(set) var cachedImageUri: String? @Published private(set) var isProfileSetupPending: Bool + /// Public profiles found for the Pubky Ring rows on the choice screen, keyed by normalized pubky. Display-only. + @Published private(set) var ringIdentityProfiles: [String: PubkyProfile] = [:] private var isSignupInFlight = false - - init() { + private let remoteProfileResolver: RemoteProfileResolver + /// Bumped whenever `profile` is written or the identity changes, so a remote read that started earlier is dropped. + private var profileWriteGeneration = 0 + /// Ring rows whose lookup found nothing. The SDK reports a missing record and an offline failure alike, so a miss + /// only stops repeat lookups and must never drive sign-up or profile-setup decisions. + private var ringIdentityMisses: Set = [] + private var ringIdentityLookups: [String: (id: UUID, task: Task)] = [:] + + init(remoteProfileResolver: @escaping RemoteProfileResolver = { try await PubkyProfileManager.resolveRemoteProfile(publicKey: $0) }) { + self.remoteProfileResolver = remoteProfileResolver cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) isProfileSetupPending = UserDefaults.standard.bool(forKey: Self.profileSetupPendingKey) @@ -304,15 +316,26 @@ class PubkyProfileManager: ObservableObject { throw error } + return await completeRingAdoption(publicKey: adoptedPublicKey) + } + + private func completeRingAdoption(publicKey adoptedPublicKey: String) async -> PubkyProfile? { + invalidateProfileLoads() publicKey = adoptedPublicKey authState = .authenticated Self.notifyAppStateBackupChanged() - let adoptedProfile = await fetchRemoteProfile(publicKey: adoptedPublicKey) + // Read only after sign-in: when lookups are contended, the tapped row's result lands after the tap. Only a found + // row profile for this key is reused; anything else takes the fetch that decides profile setup. + var adoptedProfile = ringIdentityProfiles[adoptedPublicKey] + if !PubkyPublicKeyFormat.matches(adoptedProfile?.publicKey, adoptedPublicKey) { + adoptedProfile = await fetchRemoteProfile(publicKey: adoptedPublicKey) + } + clearRingIdentityProfiles() + setProfileSetupPending(adoptedProfile == nil) if let adoptedProfile { - profile = adoptedProfile - cacheProfileMetadata(adoptedProfile) + commitProfile(adoptedProfile) } return adoptedProfile } @@ -368,8 +391,7 @@ class PubkyProfileManager: ObservableObject { ) self.publicKey = publicKey authState = .authenticated - profile = createdProfile - cacheProfileMetadata(createdProfile) + commitProfile(createdProfile) setProfileSetupPending(false) } @@ -497,8 +519,7 @@ class PubkyProfileManager: ObservableObject { tags: tags, status: profile?.status ) - profile = updatedProfile - cacheProfileMetadata(updatedProfile) + commitProfile(updatedProfile) } func deleteProfile() async throws { @@ -515,6 +536,7 @@ class PubkyProfileManager: ObservableObject { Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") } + invalidateProfileLoads() Self.clearPaykitSharingAfterProfileDeletion() try await signOut(cleanPrivatePaykitEndpoints: false) } @@ -596,6 +618,14 @@ class PubkyProfileManager: ObservableObject { forgetSessionAccess: forgetSessionAccess ) } + + func completeRingAdoptionForTesting(publicKey: String) async -> PubkyProfile? { + await completeRingAdoption(publicKey: publicKey) + } + + func clearAuthenticatedStateForTesting() { + clearAuthenticatedState() + } #endif // MARK: - Profile @@ -604,12 +634,14 @@ class PubkyProfileManager: ObservableObject { guard let pk = publicKey, !isLoadingProfile else { return } isLoadingProfile = true + let generation = profileWriteGeneration + let resolve = remoteProfileResolver do { let loadedProfile = try await Task.detached { - try await Self.resolveRemoteProfile(publicKey: pk) + try await resolve(pk) }.value - if publicKey == pk { + if publicKey == pk, profileWriteGeneration == generation { profile = loadedProfile cacheProfileMetadata(loadedProfile) } @@ -623,9 +655,9 @@ class PubkyProfileManager: ObservableObject { /// Fetch a remote profile by public key. Returns nil if no profile exists. func fetchRemoteProfile(publicKey: String) async -> PubkyProfile? { do { - return try await Self.resolveRemoteProfile(publicKey: publicKey) + return try await remoteProfileResolver(publicKey) } catch { - Logger.debug("No remote profile found for \(publicKey): \(error)", context: "PubkyProfileManager") + Logger.debug("No remote profile found for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "PubkyProfileManager") return nil } } @@ -639,6 +671,82 @@ class PubkyProfileManager: ObservableObject { throw PubkyServiceError.profileNotFound } + /// Sets a profile this device just wrote or chose, dropping any remote read that started before it. + private func commitProfile(_ newProfile: PubkyProfile) { + invalidateProfileLoads() + profile = newProfile + cacheProfileMetadata(newProfile) + } + + private func invalidateProfileLoads() { + profileWriteGeneration += 1 + } + + // MARK: - Pubky Ring Choice Rows + + /// Looks up the rows that have no cached result and no live lookup. Cancelling the caller cancels the lookups it + /// started, so a screen the user has left stops queueing for read slots. + func loadRingIdentityProfiles(_ pubkys: [String]) async { + let lookups = Set(pubkys.compactMap(PubkyPublicKeyFormat.normalized)) + .filter { needsRingIdentityLookup($0) } + .map { startRingIdentityLookup($0) } + + await withTaskCancellationHandler { + for lookup in lookups { + await lookup.value + } + } onCancel: { + lookups.forEach { $0.cancel() } + } + } + + /// Forgets the misses on returning to the app, since the user may have just published that pubky in Pubky Ring. + func forgetRingIdentityMisses() { + ringIdentityMisses.removeAll() + } + + private func needsRingIdentityLookup(_ key: String) -> Bool { + guard ringIdentityProfiles[key] == nil, !ringIdentityMisses.contains(key) else { return false } + // A cancelled lookup records no miss when it fails, so it cannot stand in for a live one. + return ringIdentityLookups[key]?.task.isCancelled ?? true + } + + private func startRingIdentityLookup(_ key: String) -> Task { + let id = UUID() + let resolve = remoteProfileResolver + let task = Task { + var foundProfile: PubkyProfile? + do { + foundProfile = try await resolve(key) + } catch { + Logger.debug("No profile found for Pubky Ring key \(PubkyPublicKeyFormat.redacted(key)): \(error)", context: "PubkyProfileManager") + } + finishRingIdentityLookup(key, id: id, foundProfile: foundProfile) + } + ringIdentityLookups[key] = (id, task) + return task + } + + /// Runs on the lookup's own task, so `Task.isCancelled` is that lookup's cancellation. + private func finishRingIdentityLookup(_ key: String, id: UUID, foundProfile: PubkyProfile?) { + // A newer lookup or a cache clear has replaced this one, so its result may be stale. + guard ringIdentityLookups[key]?.id == id else { return } + ringIdentityLookups[key] = nil + + if let foundProfile { + ringIdentityProfiles[key] = foundProfile + } else if !Task.isCancelled { + ringIdentityMisses.insert(key) + } + } + + private func clearRingIdentityProfiles() { + ringIdentityLookups.values.forEach { $0.task.cancel() } + ringIdentityLookups.removeAll() + ringIdentityMisses.removeAll() + ringIdentityProfiles.removeAll() + } + // MARK: - Sign Out static func clearLocalState() async { @@ -826,10 +934,12 @@ class PubkyProfileManager: ObservableObject { } private func clearAuthenticatedState() { + invalidateProfileLoads() publicKey = nil profile = nil authState = .idle clearCachedProfileMetadata() + clearRingIdentityProfiles() } private func activeSessionSecret() throws -> String { diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 321a84b17..7dde741ea 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -8,9 +8,10 @@ struct PubkyChoiceView: View { @Environment(\.scenePhase) private var scenePhase @State private var ringPubkys: [String] = [] - @State private var profiles: [String: PubkyProfile] = [:] + @State private var ringProfiles: [String: PubkyProfile] = [:] @State private var didLoad = false - @State private var isAdopting = false + @State private var adoptingPubky: String? + @State private var loadTask: Task? private var hasRingIdentities: Bool { !ringPubkys.isEmpty @@ -49,10 +50,18 @@ struct PubkyChoiceView: View { .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) - .task { await loadIdentities() } + .onAppear(perform: reloadIdentities) + .onDisappear(perform: cancelLoad) .onChange(of: scenePhase) { _, newPhase in guard newPhase == .active else { return } - Task { await loadIdentities() } + pubkyProfile.forgetRingIdentityMisses() + guard adoptingPubky == nil else { return } + reloadIdentities() + } + .onReceive(pubkyProfile.$ringIdentityProfiles) { found in + // Only ever add: adopting clears the manager's cache while this screen is still up, and rows must not + // flash back to bare keys before navigation. + ringProfiles.merge(found) { _, latest in latest } } } @@ -98,7 +107,7 @@ struct PubkyChoiceView: View { private func ringRow(_ pubky: String) -> some View { let truncatedKey = PubkyPublicKeyFormat.displayTruncated(pubky) - let profile = profiles[pubky] + let profile = PubkyPublicKeyFormat.normalized(pubky).flatMap { ringProfiles[$0] } let name = profile?.name ?? "" let title = name.isEmpty ? truncatedKey : name @@ -108,16 +117,24 @@ struct PubkyChoiceView: View { title: title, avatarName: title, avatarImageUrl: profile?.imageUrl, + isLoading: adoptingPubky == pubky, accessibilityId: "PubkyChoiceRing_\(pubky)" ) { - Task { await adopt(pubky) } + startAdopting(pubky) } - .disabled(isAdopting) + .disabled(adoptingPubky != nil) + } + + private func startAdopting(_ pubky: String) { + guard adoptingPubky == nil else { return } + // Row lookups still queued would only compete with sign-in. + cancelLoad() + adoptingPubky = pubky + Task { await adopt(pubky) } } private func adopt(_ pubky: String) async { - isAdopting = true - defer { isAdopting = false } + defer { adoptingPubky = nil } do { guard let adopted = try await pubkyProfile.adoptRingIdentity(pubky: pubky) else { @@ -135,21 +152,18 @@ struct PubkyChoiceView: View { } } - private func loadIdentities() async { + private func reloadIdentities() { + loadTask?.cancel() ringPubkys = SharedPubkyKeychain.listRingIdentities() didLoad = true - profiles = profiles.filter { ringPubkys.contains($0.key) } - let manager = pubkyProfile - await withTaskGroup(of: (String, PubkyProfile?).self) { group in - for pubky in ringPubkys where profiles[pubky] == nil { - group.addTask { await (pubky, manager.fetchRemoteProfile(publicKey: pubky)) } - } + let pubkys = ringPubkys + loadTask = Task { await pubkyProfile.loadRingIdentityProfiles(pubkys) } + } - for await (pubky, profile) in group { - profiles[pubky] = profile - } - } + private func cancelLoad() { + loadTask?.cancel() + loadTask = nil } // MARK: - Background Illustrations diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 27fc00c99..913e9634c 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -362,6 +362,213 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertTrue(didForgetSession) } + // MARK: - Profile load freshness + + @MainActor + func testLoadProfileDropsAResultThatPredatesAProfileWrite() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub(holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + + let adoption = Task { await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) } + await stub.waitForRequests(1) + let load = Task { await manager.loadProfile() } + await stub.waitForRequests(2) + + await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Written"), for: ringKeyA) + await stub.release(request: 0) + _ = await adoption.value + await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Stale"), for: ringKeyA) + await stub.release(request: 1) + await load.value + + XCTAssertEqual(manager.profile?.name, "Written") + XCTAssertEqual(manager.cachedName, "Written") + XCTAssertFalse(manager.isLoadingProfile) + } + } + + @MainActor + func testLoadProfileDropsAResultThatPredatesSignOutEvenForTheSameKey() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub( + profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Stale")], + holdsRequests: true + ) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + manager.publicKey = ringKeyA + + let load = Task { await manager.loadProfile() } + await stub.waitForRequests(1) + manager.clearAuthenticatedStateForTesting() + manager.publicKey = ringKeyA + await stub.release(request: 0) + await load.value + + XCTAssertNil(manager.profile) + XCTAssertNil(manager.cachedName) + } + } + + // MARK: - Pubky Ring choice rows + + @MainActor + func testRingIdentityProfilesKeepFoundProfilesAndRetryMissesOnlyAfterForeground() async { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") + XCTAssertNil(manager.ringIdentityProfiles[ringKeyB]) + + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + let requestsAfterRevisit = await stub.requests + XCTAssertEqual(requestsAfterRevisit.sorted(), [ringKeyA, ringKeyB].sorted(), "Neither a found profile nor a miss is looked up again") + + manager.forgetRingIdentityMisses() + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + let requestsAfterForeground = await stub.requests + XCTAssertEqual(requestsAfterForeground.count, 3) + XCTAssertEqual(requestsAfterForeground.last, ringKeyB) + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") + } + + @MainActor + func testRingIdentityLookupsInFlightAreNotStartedTwice() async { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + + let first = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(1) + await manager.loadRingIdentityProfiles([bareRingKeyA, ringKeyA]) + + await stub.release(request: 0) + await first.value + let requests = await stub.requests + XCTAssertEqual(requests, [ringKeyA]) + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") + } + + @MainActor + func testCancelledRingIdentityLookupRecordsNoMissAndIsNotReused() async { + let stub = RemoteProfileStub(holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + + let abandoned = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(1) + abandoned.cancel() + await stub.release(request: 0) + await abandoned.value + + let replacedAbandoned = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(2) + replacedAbandoned.cancel() + let current = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(3) + + await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice"), for: ringKeyA) + await stub.release(request: 1) + await replacedAbandoned.value + XCTAssertNil(manager.ringIdentityProfiles[ringKeyA], "A replaced lookup's result is dropped") + await stub.release(request: 2) + await current.value + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") + } + + @MainActor + func testClearingAuthenticatedStateDropsRingIdentityProfilesAndLookups() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + await stub.setHoldsRequests(true) + let inFlight = Task { await manager.loadRingIdentityProfiles([bareRingKeyC]) } + await stub.waitForRequests(3) + + manager.clearAuthenticatedStateForTesting() + await stub.setProfile(makeProfile(publicKey: ringKeyC, name: "Carol"), for: ringKeyC) + await stub.release(request: 2) + await inFlight.value + XCTAssertTrue(manager.ringIdentityProfiles.isEmpty) + + await stub.setHoldsRequests(false) + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + let requests = await stub.requests + XCTAssertEqual(requests.count, 5, "The miss is forgotten along with the found profile") + } + } + + @MainActor + func testRingAdoptionReusesTheFoundRowProfileAndClearsTheRows() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + + let adopted = await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + + let requests = await stub.requests + XCTAssertEqual(requests.count, 2, "Adoption reuses the row's profile instead of fetching it again") + XCTAssertEqual(adopted?.name, "Alice") + XCTAssertEqual(manager.profile?.name, "Alice") + XCTAssertEqual(manager.cachedName, "Alice") + XCTAssertEqual(manager.publicKey, ringKeyA) + XCTAssertEqual(manager.authState, .authenticated) + XCTAssertFalse(manager.isProfileSetupPending) + XCTAssertTrue(manager.ringIdentityProfiles.isEmpty) + } + } + + @MainActor + func testRingAdoptionAfterARowMissStillFetchesTheProfile() async { + await withRestoredProfileDefaults { + // A row miss can mean offline, so it never stands in for the fetch that decides profile setup. + let stub = RemoteProfileStub() + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyB]) + await stub.setProfile(makeProfile(publicKey: ringKeyB, name: "Bob"), for: ringKeyB) + + let adopted = await manager.completeRingAdoptionForTesting(publicKey: ringKeyB) + + let requests = await stub.requests + XCTAssertEqual(requests, [ringKeyB, ringKeyB]) + XCTAssertEqual(adopted?.name, "Bob") + XCTAssertEqual(manager.profile?.name, "Bob") + XCTAssertFalse(manager.isProfileSetupPending) + } + } + + @MainActor + func testRingAdoptionFetchesWhenTheRowProfileIsForAnotherKey() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyC: makeProfile(publicKey: ringKeyB, name: "Other")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyC]) + + _ = await manager.completeRingAdoptionForTesting(publicKey: ringKeyC) + + let requests = await stub.requests + XCTAssertEqual(requests, [ringKeyC, ringKeyC]) + } + } + + @MainActor + func testRingAdoptionWithoutARowProfileKeepsFetchAndSetupLogic() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub() + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + + let adopted = await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + + let requests = await stub.requests + XCTAssertEqual(requests, [ringKeyA]) + XCTAssertNil(adopted) + XCTAssertNil(manager.profile) + XCTAssertEqual(manager.publicKey, ringKeyA) + XCTAssertTrue(manager.isProfileSetupPending) + } + } + // MARK: - HomegateResponse Decoding private typealias HomegateResponse = PubkyProfileManager.HomegateResponse @@ -945,6 +1152,21 @@ final class PubkyProfileManagerTests: XCTestCase { ) } + /// Profile commits and clears write these keys to the standard defaults. + @MainActor + private func withRestoredProfileDefaults(_ body: () async -> Void) async { + let defaults = UserDefaults.standard + let keys = ["pubky_profile_name", "pubky_profile_image_uri", "pubky_profile_setup_pending"] + let previousValues = keys.map { defaults.object(forKey: $0) } + defer { + for (key, value) in zip(keys, previousValues) { + defaults.set(value, forKey: key) + } + } + + await body() + } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { let suiteName = "PubkyProfileManagerTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -998,6 +1220,59 @@ final class PubkyProfileManagerTests: XCTestCase { } } +private let bareRingKeyA = String(repeating: "y", count: 52) +private let bareRingKeyB = String(repeating: "b", count: 52) +private let bareRingKeyC = String(repeating: "n", count: 52) +private let ringKeyA = "pubky\(bareRingKeyA)" +private let ringKeyB = "pubky\(bareRingKeyB)" +private let ringKeyC = "pubky\(bareRingKeyC)" + +/// Stands in for the remote profile lookup. While holding, each request waits for its own `release(request:)` and then +/// answers from the profiles set at that moment; a key with no profile fails as not found. +private actor RemoteProfileStub { + private(set) var requests: [String] = [] + private var profiles: [String: PubkyProfile] + private var isHolding: Bool + private var heldRequests: [Int: CheckedContinuation] = [:] + private var requestWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + init(profiles: [String: PubkyProfile] = [:], holdsRequests: Bool = false) { + self.profiles = profiles + isHolding = holdsRequests + } + + func resolve(_ key: String) async throws -> PubkyProfile { + let index = requests.count + requests.append(key) + let satisfiedWaiters = requestWaiters.filter { $0.count <= requests.count } + requestWaiters.removeAll { $0.count <= requests.count } + satisfiedWaiters.forEach { $0.continuation.resume() } + + if isHolding { + await withCheckedContinuation { heldRequests[index] = $0 } + } + guard let profile = profiles[key] else { throw PubkyServiceError.profileNotFound } + return profile + } + + func waitForRequests(_ count: Int) async { + guard requests.count < count else { return } + await withCheckedContinuation { requestWaiters.append((count, $0)) } + } + + func setProfile(_ profile: PubkyProfile, for key: String) { + profiles[key] = profile + } + + func setHoldsRequests(_ holds: Bool) { + isHolding = holds + } + + func release(request index: Int) { + heldRequests.removeValue(forKey: index)?.resume() + } +} + @MainActor private class KeyDerivationProbeProfileManager: PubkyProfileManager { var didDeriveKeys = false From 573d235961fe67db9920045a7f1139aa08470c26 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 18:48:28 +0200 Subject: [PATCH 03/79] fix: show the cached profile header while the profile loads --- Bitkit/Managers/PubkyProfileManager.swift | 22 ++++++++++ Bitkit/Views/Profile/ProfileView.swift | 25 +++++++++++ BitkitTests/PubkyProfileManagerTests.swift | 51 +++++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index ea040a3f8..25cf75781 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -43,6 +43,8 @@ class PubkyProfileManager: ObservableObject { @Published var adoptedSourceLost = false @Published private(set) var cachedName: String? @Published private(set) var cachedImageUri: String? + /// The pubky the cached name and avatar were written for. Nil for a cache written before the owner was stored. + @Published private var cachedProfileOwner: String? @Published private(set) var isProfileSetupPending: Bool /// Public profiles found for the Pubky Ring rows on the choice screen, keyed by normalized pubky. Display-only. @Published private(set) var ringIdentityProfiles: [String: PubkyProfile] = [:] @@ -60,6 +62,7 @@ class PubkyProfileManager: ObservableObject { self.remoteProfileResolver = remoteProfileResolver cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) + cachedProfileOwner = UserDefaults.standard.string(forKey: Self.cachedProfileOwnerKey) isProfileSetupPending = UserDefaults.standard.bool(forKey: Self.profileSetupPendingKey) } @@ -767,6 +770,7 @@ class PubkyProfileManager: ObservableObject { await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + UserDefaults.standard.removeObject(forKey: cachedProfileOwnerKey) UserDefaults.standard.removeObject(forKey: profileSetupPendingKey) ContactsManager.restoreContactProfileOverrides(nil) clearPublicPaykitSharingState() @@ -904,6 +908,7 @@ class PubkyProfileManager: ObservableObject { private static let cachedNameKey = "pubky_profile_name" private static let cachedImageUriKey = "pubky_profile_image_uri" + private static let cachedProfileOwnerKey = "pubky_profile_owner" private static let profileSetupPendingKey = "pubky_profile_setup_pending" var displayName: String? { @@ -914,18 +919,35 @@ class PubkyProfileManager: ObservableObject { profile?.imageUrl ?? cachedImageUri } + /// The cached name and avatar for the signed-in pubky, shown read-only while its profile loads. It has no bio, links + /// or tags, so it must never stand in for `profile`. Nil before initialization and when the cache belongs to + /// another pubky or has no recorded owner. + var cachedProfilePreview: PubkyProfile? { + guard isInitialized, + let publicKey, + let cachedName, + PubkyPublicKeyFormat.matches(cachedProfileOwner, publicKey) + else { return nil } + + return .forDisplay(publicKey: publicKey, name: cachedName, imageUrl: cachedImageUri) + } + private func cacheProfileMetadata(_ profile: PubkyProfile) { cachedName = profile.name cachedImageUri = profile.imageUrl + cachedProfileOwner = profile.publicKey UserDefaults.standard.set(profile.name, forKey: Self.cachedNameKey) UserDefaults.standard.set(profile.imageUrl, forKey: Self.cachedImageUriKey) + UserDefaults.standard.set(profile.publicKey, forKey: Self.cachedProfileOwnerKey) } private func clearCachedProfileMetadata() { cachedName = nil cachedImageUri = nil + cachedProfileOwner = nil UserDefaults.standard.removeObject(forKey: Self.cachedNameKey) UserDefaults.standard.removeObject(forKey: Self.cachedImageUriKey) + UserDefaults.standard.removeObject(forKey: Self.cachedProfileOwnerKey) } private func setProfileSetupPending(_ pending: Bool) { diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 0bae602a2..01b821c25 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -16,6 +16,8 @@ struct ProfileView: View { Group { if let profile = pubkyProfile.profile { profileContent(profile) + } else if pubkyProfile.isLoadingProfile, let cachedProfile = pubkyProfile.cachedProfilePreview { + cachedProfileContent(cachedProfile) } else { VStack(spacing: 0) { navigationBar @@ -247,6 +249,29 @@ struct ProfileView: View { // MARK: - Loading / Empty States + /// Read-only, and laid out like `profileContent` so the name and avatar stay put when the profile arrives. + private func cachedProfileContent(_ cachedProfile: PubkyProfile) -> some View { + InsetHeaderScrollView(header: { navigationBar }) { + VStack(spacing: 0) { + CenteredProfileHeader( + truncatedKey: cachedProfile.truncatedPublicKey, + name: cachedProfile.name, + bio: "", + imageUrl: cachedProfile.imageUrl, + showDivider: false, + nameAccessibilityIdentifier: "ProfileCachedName" + ) + .padding(.top, 16) + .padding(.bottom, 16) + + ActivityIndicator(size: 24) + } + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("ProfileCachedHeader") + } + } + private var loadingContent: some View { VStack { Spacer() diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 913e9634c..06a8b9b45 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -410,6 +410,55 @@ final class PubkyProfileManagerTests: XCTestCase { } } + // MARK: - Cached profile preview + + @MainActor + func testCachedProfilePreviewShowsOnlyForThePubkyItWasCachedFor() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + manager.publicKey = ringKeyA + await manager.loadProfile() + manager.isInitialized = true + + XCTAssertEqual(manager.cachedProfilePreview?.name, "Alice") + manager.publicKey = bareRingKeyA + XCTAssertEqual(manager.cachedProfilePreview?.name, "Alice", "The owner matches either key form") + manager.publicKey = ringKeyB + XCTAssertNil(manager.cachedProfilePreview, "Another pubky never shows this pubky's name") + XCTAssertEqual(manager.cachedName, "Alice") + + let relaunched = PubkyProfileManager() + relaunched.publicKey = ringKeyA + XCTAssertNil(relaunched.cachedProfilePreview, "Nothing shows before initialization completes") + relaunched.isInitialized = true + XCTAssertEqual(relaunched.cachedProfilePreview?.name, "Alice", "The owner persists across launches") + + manager.clearAuthenticatedStateForTesting() + XCTAssertNil(UserDefaults.standard.string(forKey: "pubky_profile_owner")) + } + } + + @MainActor + func testCachedNameWithoutAnOwnerShowsNoPreviewUntilALoadRecordsOne() async { + await withRestoredProfileDefaults { + // Builds before the owner was stored cached only the name and avatar. + UserDefaults.standard.set("Legacy", forKey: "pubky_profile_name") + UserDefaults.standard.removeObject(forKey: "pubky_profile_owner") + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + manager.publicKey = ringKeyA + manager.isInitialized = true + + XCTAssertEqual(manager.cachedName, "Legacy") + XCTAssertNil(manager.cachedProfilePreview) + + await manager.loadProfile() + XCTAssertEqual(manager.cachedProfilePreview?.name, "Alice") + XCTAssertEqual(UserDefaults.standard.string(forKey: "pubky_profile_owner"), ringKeyA) + } + } + // MARK: - Pubky Ring choice rows @MainActor @@ -1156,7 +1205,7 @@ final class PubkyProfileManagerTests: XCTestCase { @MainActor private func withRestoredProfileDefaults(_ body: () async -> Void) async { let defaults = UserDefaults.standard - let keys = ["pubky_profile_name", "pubky_profile_image_uri", "pubky_profile_setup_pending"] + let keys = ["pubky_profile_name", "pubky_profile_image_uri", "pubky_profile_owner", "pubky_profile_setup_pending"] let previousValues = keys.map { defaults.object(forKey: $0) } defer { for (key, value) in zip(keys, previousValues) { From 7fba72ec79bd900bba722d74e13ba98f4a5e6730 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 18:59:33 +0200 Subject: [PATCH 04/79] fix: stop retrying contact profile lookups that cannot succeed A missing profile is never retried, and bulk contact loads and follow discovery no longer retry a failed lookup, so each contact without a published profile costs one lookup instead of two. Adding a contact and importing contacts still retry a failed lookup once, and a lookup cancelled during that wait no longer starts the retry. --- Bitkit/Managers/ContactsManager.swift | 46 ++++++--- Bitkit/Views/Contacts/AddContactView.swift | 2 +- BitkitTests/ContactsManagerTests.swift | 106 +++++++++++++++++++++ 3 files changed, 142 insertions(+), 12 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index edde0e68c..2e725e1c2 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -293,7 +293,7 @@ class ContactsManager: ObservableObject { status: existingProfile.status ) } else { - try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true) + try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true, retryTransient: true) } let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey) @@ -340,7 +340,7 @@ class ContactsManager: ObservableObject { for key in prefixedKeys { group.addTask { [self] in do { - let profile = try await resolveContactProfile(publicKey: key, includePlaceholder: true) + let profile = try await resolveContactProfile(publicKey: key, includePlaceholder: true, retryTransient: true) let receiverPaths = try await Self.relevantReceiverPaths(for: key) _ = try await PubkyService.saveContact(publicKey: key, label: profile.name, receiverPaths: receiverPaths) return .success(PubkyContact(publicKey: key, profile: profile)) @@ -568,13 +568,13 @@ class ContactsManager: ObservableObject { // MARK: - Contact Profile Resolution - func fetchContactProfile(publicKey: String, includePlaceholder: Bool = false) async -> PubkyProfile? { + func fetchContactProfile(publicKey: String, includePlaceholder: Bool = false, retryTransient: Bool = false) async -> PubkyProfile? { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return nil } do { - return try await resolveContactProfile(publicKey: normalizedKey, includePlaceholder: includePlaceholder) + return try await resolveContactProfile(publicKey: normalizedKey, includePlaceholder: includePlaceholder, retryTransient: retryTransient) } catch { return nil } @@ -597,25 +597,39 @@ class ContactsManager: ObservableObject { saveContactProfileOverrides(overrides) } - private func resolveContactProfile(publicKey: String, includePlaceholder: Bool = false) async throws -> PubkyProfile { - try await Self.resolveContactProfile(publicKey: publicKey, includePlaceholder: includePlaceholder) + private func resolveContactProfile( + publicKey: String, + includePlaceholder: Bool = false, + retryTransient: Bool = false + ) async throws -> PubkyProfile { + try await Self.resolveContactProfile(publicKey: publicKey, includePlaceholder: includePlaceholder, retryTransient: retryTransient) } - private nonisolated static func resolveContactProfile(publicKey: String, includePlaceholder: Bool = false) async throws -> PubkyProfile { + /// A missing profile is never retried. `retryTransient` retries any other failure once and is for user-initiated + /// lookups only: the SDK reports a key with no pkarr record and a network failure as the same transport error, so a + /// retry in bulk loads doubles the wait for every contact without a profile. + nonisolated static func resolveContactProfile( + publicKey: String, + includePlaceholder: Bool = false, + retryTransient: Bool = false, + fetchRemoteProfile: @Sendable (String) async throws -> PubkyProfile? = { + try await PubkyService.resolveContactProfile(publicKey: $0, allowPubkyProfileFallback: true).map(PubkyProfile.init(resolution:)) + } + ) async throws -> PubkyProfile { let prefixedKey = ensurePubkyPrefix(publicKey) for attempt in 0 ..< 2 { do { - if let resolution = try await PubkyService.resolveContactProfile(publicKey: prefixedKey, allowPubkyProfileFallback: true) { - return PubkyProfile(resolution: resolution) + if let profile = try await fetchRemoteProfile(prefixedKey) { + return profile } throw PubkyServiceError.profileNotFound } catch { - if attempt == 0, !(error is CancellationError) { + if retryTransient, attempt == 0, isRetryableContactProfileError(error) { Logger.warn( "Retrying contact profile resolution for '\(PubkyPublicKeyFormat.redacted(prefixedKey))' after transient error: \(error)", context: "ContactsManager" ) - try? await Task.sleep(nanoseconds: 250_000_000) + try await Task.sleep(nanoseconds: 250_000_000) continue } @@ -641,6 +655,16 @@ class ContactsManager: ObservableObject { throw PubkyServiceError.profileNotFound } + private nonisolated static func isRetryableContactProfileError(_ error: Error) -> Bool { + if error is CancellationError { + return false + } + if case .profileNotFound? = error as? PubkyServiceError { + return false + } + return true + } + private nonisolated static func relevantReceiverPaths(for publicKey: String) async throws -> [String] { do { return try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey) diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 0abe85ead..6726831ef 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -220,7 +220,7 @@ struct AddContactView: View { } return case let .valid(normalizedKey): - if let profile = await contactsManager.fetchContactProfile(publicKey: normalizedKey, includePlaceholder: true) { + if let profile = await contactsManager.fetchContactProfile(publicKey: normalizedKey, includePlaceholder: true, retryTransient: true) { fetchedProfile = profile hasPayableEndpoint = await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: normalizedKey)) == true } else { diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 09ae6b7f1..9a74b3ebf 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import BitkitCore +import enum Paykit.PaykitError import XCTest @MainActor @@ -246,6 +247,91 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(ContactsManager.isMissingContactsDataError(error)) } + func testContactProfileLookupDoesNotRetryMissingProfile() async throws { + for outcome: Result in [.success(nil), .failure(PubkyServiceError.profileNotFound)] { + let stub = ContactProfileFetchStub([outcome, .success(makeProfile(publicKey: contactProfileKey))]) + + do { + _ = try await ContactsManager.resolveContactProfile( + publicKey: contactProfileKey, + retryTransient: true, + fetchRemoteProfile: { try await stub.fetch($0) } + ) + XCTFail("Expected a missing profile to throw") + } catch PubkyServiceError.profileNotFound { + // Expected. + } + + let attempts = await stub.attempts + XCTAssertEqual(attempts, 1) + } + } + + func testBulkContactProfileLookupDoesNotRetryTransportError() async throws { + let stub = ContactProfileFetchStub([.failure(profileTransportError), .success(makeProfile(publicKey: contactProfileKey))]) + + let profile = try await ContactsManager.resolveContactProfile( + publicKey: contactProfileKey, + includePlaceholder: true, + fetchRemoteProfile: { try await stub.fetch($0) } + ) + + XCTAssertEqual(profile.name, Bitkit.PubkyProfile.placeholder(publicKey: contactProfileKey).name) + let attempts = await stub.attempts + XCTAssertEqual(attempts, 1) + } + + func testUserInitiatedContactProfileLookupRetriesTransportErrorOnce() async throws { + let stub = ContactProfileFetchStub([.failure(profileTransportError), .success(makeProfile(publicKey: contactProfileKey))]) + + let profile = try await ContactsManager.resolveContactProfile( + publicKey: contactProfileKey, + includePlaceholder: true, + retryTransient: true, + fetchRemoteProfile: { try await stub.fetch($0) } + ) + + XCTAssertEqual(profile.name, "Alice") + let attempts = await stub.attempts + XCTAssertEqual(attempts, 2) + } + + func testImportContactProfileLookupFallsBackToPlaceholderAfterOneRetry() async throws { + let stub = ContactProfileFetchStub([.failure(profileTransportError)]) + + let profile = try await ContactsManager.resolveContactProfile( + publicKey: contactProfileKey, + includePlaceholder: true, + retryTransient: true, + fetchRemoteProfile: { try await stub.fetch($0) } + ) + + XCTAssertEqual(profile.name, Bitkit.PubkyProfile.placeholder(publicKey: contactProfileKey).name) + let attempts = await stub.attempts + XCTAssertEqual(attempts, 2) + } + + func testContactProfileRetryStopsWhenCancelledDuringBackoff() async { + let stub = ContactProfileFetchStub([.failure(profileTransportError), .success(makeProfile(publicKey: contactProfileKey))]) + + let lookup = Task { + try await ContactsManager.resolveContactProfile( + publicKey: contactProfileKey, + includePlaceholder: true, + retryTransient: true, + fetchRemoteProfile: { publicKey in + withUnsafeCurrentTask { $0?.cancel() } + return try await stub.fetch(publicKey) + } + ) + } + + let result = await lookup.result + XCTAssertThrowsError(try result.get()) { XCTAssertTrue($0 is CancellationError, "Expected cancellation, got \($0)") } + let attempts = await stub.attempts + XCTAssertEqual(attempts, 1) + } + func testShouldDiscardPendingImportWhenLeavingImportFlow() { XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .contacts)) XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportSelect, destination: nil)) @@ -372,3 +458,23 @@ final class ContactsManagerTests: XCTestCase { Bitkit.PubkyContact(publicKey: publicKey, profile: makeProfile(publicKey: publicKey)) } } + +private let contactProfileKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" +/// What the SDK returns both for a network failure and for a key with no pkarr record. +private let profileTransportError = PaykitError.Transport(code: "transport_error", context: "fetch profile") + +/// Answers each fetch with the next scripted outcome, repeating the last one once the script runs out. +private actor ContactProfileFetchStub { + private(set) var attempts = 0 + private var outcomes: [Result] + + init(_ outcomes: [Result]) { + self.outcomes = outcomes + } + + func fetch(_: String) throws -> Bitkit.PubkyProfile? { + attempts += 1 + let outcome = outcomes.count > 1 ? outcomes.removeFirst() : outcomes[0] + return try outcome.get() + } +} From 11fff39d8527af0f9fad70e87ef84d41b197eba9 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 20:35:18 +0200 Subject: [PATCH 05/79] fix: keep public reads off the sdk lock after sign-out and drop stale avatar stores --- Bitkit/Components/PubkyImage.swift | 22 +++++++++--- Bitkit/Services/PubkyService.swift | 15 ++++---- BitkitTests/PubkyIdentityRepublishTests.swift | 16 +++++++-- BitkitTests/PubkyImageCacheTests.swift | 36 +++++++++++++++---- 4 files changed, 69 insertions(+), 20 deletions(-) diff --git a/Bitkit/Components/PubkyImage.swift b/Bitkit/Components/PubkyImage.swift index b38d56af0..d47a7f565 100644 --- a/Bitkit/Components/PubkyImage.swift +++ b/Bitkit/Components/PubkyImage.swift @@ -72,12 +72,13 @@ struct PubkyImage: View { return cached } + let cacheGeneration = PubkyImageCache.shared.generation let data = try await PubkyService.fetchFile(uri: uri, maxBytes: PubkyImagePolicy.maxDownloadBytes) let blobData = try await resolveImageData(data, originalUri: uri) let image = try PubkyImageDecoder.image(from: blobData) - PubkyImageCache.shared.store(image, data: blobData, for: uri) + PubkyImageCache.shared.store(image, data: blobData, for: uri, generation: cacheGeneration) return image } @@ -158,6 +159,7 @@ final class PubkyImageCache: @unchecked Sendable { private var memoryCache: [String: MemoryEntry] = [:] private var memoryCost = 0 private var accessSequence: UInt64 = 0 + private var clearGeneration: UInt64 = 0 private let memoryLock = NSLock() private let diskQueue = DispatchQueue(label: "pubky-image-cache-disk", qos: .utility) private let diskDirectory: URL @@ -225,12 +227,22 @@ final class PubkyImageCache: @unchecked Sendable { } } - func store(_ image: UIImage, data: Data, for uri: String) { + /// Bumped by `clear()`. Capture it before fetching and pass it to `store`, so a fetch that finishes after a sign-out + /// or wipe cannot put the previous identity's image back. + var generation: UInt64 { + memoryLock.lock() + defer { memoryLock.unlock() } + return clearGeneration + } + + /// Drops the write, in memory and on disk, when `clear()` ran after `generation` was captured. + func store(_ image: UIImage, data: Data, for uri: String, generation: UInt64) { guard data.count <= maxFileBytes else { return } - storeInMemory(image, for: uri) + storeInMemory(image, for: uri, generation: generation) diskQueue.async { [self] in + guard generation == self.generation else { return } let path = diskPath(for: uri) try? data.write(to: path, options: .atomic) trimDiskCache() @@ -251,6 +263,7 @@ final class PubkyImageCache: @unchecked Sendable { private func clearMemoryCache() { memoryLock.lock() + clearGeneration &+= 1 memoryCache.removeAll() memoryCost = 0 memoryLock.unlock() @@ -270,12 +283,13 @@ final class PubkyImageCache: @unchecked Sendable { return cgImage.bytesPerRow * cgImage.height } - private func storeInMemory(_ image: UIImage, for uri: String) { + private func storeInMemory(_ image: UIImage, for uri: String, generation: UInt64? = nil) { let cost = Self.memoryCost(of: image) memoryLock.lock() defer { memoryLock.unlock() } + if let generation, generation != clearGeneration { return } if let replaced = memoryCache.removeValue(forKey: uri) { memoryCost -= replaced.cost } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index a3bad4cbc..86dfb1a9e 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -979,16 +979,15 @@ actor PaykitSdkService { /// Public-only read lane: skips `operationLock` and runs up to the limiter's cap at once. It is sound only for reads /// that fetch unauthenticated public Pubky data; in paykit rc56 those use the SDK's public client and never load /// session access, the local secret or the state blob. Session, secret or state-blob operations must never use it. - /// Without an SDK it takes the locked path, so a read never builds one outside the lock. + /// Without an SDK it takes `operationLock` only to build one, cancellably, and releases it before the read, so a + /// read never builds an SDK outside the lock and never holds the lock across the network call. private func withPublicRead(_ read: (PaykitSdk) async throws -> T) async throws -> T { - if sdk != nil { - let result: T? = try await publicReadLimiter.withSlot { - guard let sdk else { return nil } - return try await read(sdk) - } - if let result { return result } + let instance: PaykitSdk = if let sdk { + sdk + } else { + try await operationLock.withCancellableLock { try handle() } } - return try await operationLock.withLock { try await read(handle()) } + return try await publicReadLimiter.withSlot { try await read(instance) } } private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift index beaa571a2..675d35354 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -111,12 +111,21 @@ final class PubkyIdentityRepublishTests: XCTestCase { func testStartedRepublishReturnsWhilePublicationIsStillRunning() async { let started = expectation(description: "Publication started") + let finishedBeforeGateOpened = expectation(description: "Publication finished before the gate opened") + finishedBeforeGateOpened.isInverted = true let finished = expectation(description: "Publication finished") let gate = AsyncStream.makeStream() let bootstrap = RepublishBootstrap(noPointer: .init()) bootstrap.operation = { _ in started.fulfill() - for await _ in gate.stream {} + var gateOpened = false + for await _ in gate.stream { + gateOpened = true + break + } + if !gateOpened { + finishedBeforeGateOpened.fulfill() + } finished.fulfill() return true } @@ -124,9 +133,12 @@ final class PubkyIdentityRepublishTests: XCTestCase { await service.startIdentityRepublish(publicKey: publicKey) await fulfillment(of: [started], timeout: 1) + // A caller that awaited the republish would return only once its timeout cancelled the publication, ending the + // gate loop without the gate opening. + await fulfillment(of: [finishedBeforeGateOpened], timeout: 0.2) XCTAssertEqual(bootstrap.publicKeys, ["pubky\(publicKey)"]) - gate.continuation.finish() + gate.continuation.yield() await fulfillment(of: [finished], timeout: 1) } diff --git a/BitkitTests/PubkyImageCacheTests.swift b/BitkitTests/PubkyImageCacheTests.swift index 2ec3fa4fb..dbf052932 100644 --- a/BitkitTests/PubkyImageCacheTests.swift +++ b/BitkitTests/PubkyImageCacheTests.swift @@ -19,7 +19,7 @@ final class PubkyImageCacheTests: XCTestCase { let diskPath = pubkyImageDiskPath(for: uri, directory: directory) await cache.clear() - cache.store(image, data: imageData, for: uri) + cache.store(image, data: imageData, for: uri, generation: cache.generation) XCTAssertNotNil(cache.memoryImage(for: uri)) @@ -34,6 +34,30 @@ final class PubkyImageCacheTests: XCTestCase { XCTAssertNil(diskImage) } + func testStoreCapturedBeforeClearIsDroppedFromMemoryAndDisk() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = PubkyImageCache(diskDirectory: directory) + let staleURI = "pubky://previous-user/pub/bitkit.to/blobs/avatar.jpg" + let currentURI = "pubky://current-user/pub/bitkit.to/blobs/avatar.jpg" + let avatar = image(color: .red) + let imageData = try XCTUnwrap(avatar.pngData()) + let stalePath = pubkyImageDiskPath(for: staleURI, directory: directory) + let currentPath = pubkyImageDiskPath(for: currentURI, directory: directory) + let generationBeforeClear = cache.generation + + await cache.clear() + cache.store(avatar, data: imageData, for: staleURI, generation: generationBeforeClear) + cache.store(avatar, data: imageData, for: currentURI, generation: cache.generation) + + XCTAssertNil(cache.memoryImage(for: staleURI)) + XCTAssertNotNil(cache.memoryImage(for: currentURI)) + // The disk queue is serial, so once the later write lands the stale one has already been skipped. + let currentFileStored = await waitForFile(at: currentPath) + XCTAssertTrue(currentFileStored) + XCTAssertFalse(FileManager.default.fileExists(atPath: stalePath.path)) + } + func testDecoderDownsamplesLargeImagesBeforeCaching() throws { let format = UIGraphicsImageRendererFormat() format.scale = 1 @@ -77,11 +101,11 @@ final class PubkyImageCacheTests: XCTestCase { let firstPath = pubkyImageDiskPath(for: firstURI, directory: directory) let secondPath = pubkyImageDiskPath(for: secondURI, directory: directory) - cache.store(image, data: imageData, for: firstURI) + cache.store(image, data: imageData, for: firstURI, generation: cache.generation) let storedFirstFile = await waitForFile(at: firstPath) XCTAssertTrue(storedFirstFile) try FileManager.default.setAttributes([.modificationDate: Date.distantPast], ofItemAtPath: firstPath.path) - cache.store(image, data: imageData, for: secondURI) + cache.store(image, data: imageData, for: secondURI, generation: cache.generation) let storedSecondFile = await waitForFile(at: secondPath) let removedFirstFile = await waitForMissingFile(at: firstPath) @@ -107,11 +131,11 @@ final class PubkyImageCacheTests: XCTestCase { diskByteLimit: imageData.count * 3 ) - cache.store(firstImage, data: imageData, for: firstURI) - cache.store(replacementImage, data: imageData, for: firstURI) + cache.store(firstImage, data: imageData, for: firstURI, generation: cache.generation) + cache.store(replacementImage, data: imageData, for: firstURI, generation: cache.generation) XCTAssertEqual(cache.memoryImage(for: firstURI)?.pngData(), replacementImage.pngData()) - cache.store(firstImage, data: imageData, for: secondURI) + cache.store(firstImage, data: imageData, for: secondURI, generation: cache.generation) XCTAssertNil(cache.memoryImage(for: firstURI)) XCTAssertNotNil(cache.memoryImage(for: secondURI)) } From 60e27db570d385a1aa6f4e51dfc01636dfc6a3de Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 20:44:23 +0200 Subject: [PATCH 06/79] fix: keep loaded contacts when a contacts reload is cancelled --- Bitkit/Managers/ContactsManager.swift | 45 ++++++++++++++++---- Bitkit/Views/Profile/CreateProfileView.swift | 2 + BitkitTests/ContactsManagerTests.swift | 42 ++++++++++++++++++ 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 2e725e1c2..95e35af46 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -168,6 +168,19 @@ class ContactsManager: ObservableObject { } func loadContacts(for publicKey: String) async throws { + try await loadContacts( + for: publicKey, + fetchContactRecords: { try await PubkyService.contactRecords() }, + fetchRemoteProfile: Self.fetchRemoteContactProfile + ) + } + + /// A cancelled load publishes nothing: lookups it abandoned would otherwise replace loaded profiles with placeholders. + func loadContacts( + for publicKey: String, + fetchContactRecords: @escaping @Sendable () async throws -> [Paykit.ContactRecord], + fetchRemoteProfile: @escaping @Sendable (String) async throws -> PubkyProfile? + ) async throws { guard !isLoading else { Logger.debug("loadContacts skipped — already loading", context: "ContactsManager") return @@ -181,8 +194,9 @@ class ContactsManager: ObservableObject { do { let records = try await Task.detached { - try await PubkyService.contactRecords() + try await fetchContactRecords() }.value + guard !Task.isCancelled else { return } Logger.debug("Loaded \(records.count) SDK contact records", context: "ContactsManager") @@ -192,7 +206,12 @@ class ContactsManager: ObservableObject { for record in records { group.addTask { do { - let contact = try await Self.contact(from: record, overrides: overrides, includePlaceholder: true) + let contact = try await Self.contact( + from: record, + overrides: overrides, + includePlaceholder: true, + fetchRemoteProfile: fetchRemoteProfile + ) return .success(contact) } catch { Logger.warn( @@ -224,6 +243,7 @@ class ContactsManager: ObservableObject { return (results, failures, missingFailures, firstError) } + guard !Task.isCancelled else { return } if !records.isEmpty, loadedResult.contacts.isEmpty { if loadedResult.failures == loadedResult.missingFailures { @@ -605,6 +625,10 @@ class ContactsManager: ObservableObject { try await Self.resolveContactProfile(publicKey: publicKey, includePlaceholder: includePlaceholder, retryTransient: retryTransient) } + nonisolated static let fetchRemoteContactProfile: @Sendable (String) async throws -> PubkyProfile? = { + try await PubkyService.resolveContactProfile(publicKey: $0, allowPubkyProfileFallback: true).map(PubkyProfile.init(resolution:)) + } + /// A missing profile is never retried. `retryTransient` retries any other failure once and is for user-initiated /// lookups only: the SDK reports a key with no pkarr record and a network failure as the same transport error, so a /// retry in bulk loads doubles the wait for every contact without a profile. @@ -612,9 +636,7 @@ class ContactsManager: ObservableObject { publicKey: String, includePlaceholder: Bool = false, retryTransient: Bool = false, - fetchRemoteProfile: @Sendable (String) async throws -> PubkyProfile? = { - try await PubkyService.resolveContactProfile(publicKey: $0, allowPubkyProfileFallback: true).map(PubkyProfile.init(resolution:)) - } + fetchRemoteProfile: @Sendable (String) async throws -> PubkyProfile? = ContactsManager.fetchRemoteContactProfile ) async throws -> PubkyProfile { let prefixedKey = ensurePubkyPrefix(publicKey) for attempt in 0 ..< 2 { @@ -684,7 +706,8 @@ class ContactsManager: ObservableObject { private nonisolated static func contact( from record: Paykit.ContactRecord, overrides: [String: PubkyProfileData], - includePlaceholder: Bool + includePlaceholder: Bool, + fetchRemoteProfile: @Sendable (String) async throws -> PubkyProfile? ) async throws -> PubkyContact { let prefixedKey = PubkyPublicKeyFormat.normalized(record.publicKey) ?? ensurePubkyPrefix(record.publicKey) @@ -699,14 +722,18 @@ class ContactsManager: ObservableObject { } do { - let profile = try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: includePlaceholder) - .withNameFallback(record.label) + let profile = try await resolveContactProfile( + publicKey: prefixedKey, + includePlaceholder: includePlaceholder, + fetchRemoteProfile: fetchRemoteProfile + ) + .withNameFallback(record.label) return PubkyContact( publicKey: prefixedKey, profile: profile ) } catch { - if !includePlaceholder { + if !includePlaceholder || error is CancellationError { throw error } } diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index 2e5528406..f563ad94c 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -191,6 +191,8 @@ struct CreateProfileView: View { } catch PubkyServiceError.profileNotFound { remoteLookupFailed = false return nil + } catch is CancellationError { + return nil } catch { Logger.warn("Failed to look up the existing profile: \(error)", context: "CreateProfileView") remoteLookupFailed = true diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 9a74b3ebf..2c5b84d18 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import BitkitCore +import struct Paykit.ContactRecord import enum Paykit.PaykitError import XCTest @@ -332,6 +333,47 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(attempts, 1) } + func testCancelledContactsReloadKeepsLoadedContacts() async throws { + let manager = ContactsManager() + manager.contacts = [makeContact(publicKey: contactProfileKey)] + let hasLoadedBefore = manager.hasLoaded + let record = ContactRecord( + publicKey: contactProfileKey, + receiverPaths: [], + label: "Label only", + profile: nil, + profileFetchedAt: nil, + createdAt: "", + updatedAt: "", + publicContactMarkerStatus: .notPublished, + publicContactMarkerReceiverPath: nil, + publicContactPublishedAt: nil, + publicContactRemovedAt: nil, + publicContactLastError: nil + ) + let lookupStarted = expectation(description: "profile lookup started") + + let reload = Task { + try await manager.loadContacts( + for: contactProfileKey, + fetchContactRecords: { [record] }, + fetchRemoteProfile: { _ in + lookupStarted.fulfill() + try await Task.sleep(nanoseconds: 60_000_000_000) + return nil + } + ) + } + await fulfillment(of: [lookupStarted], timeout: 2) + reload.cancel() + try await reload.value + + XCTAssertEqual(manager.contacts.map(\.profile.name), ["Alice"]) + XCTAssertEqual(manager.hasLoaded, hasLoadedBefore) + XCTAssertNil(manager.loadErrorMessage) + XCTAssertFalse(manager.isLoading) + } + func testShouldDiscardPendingImportWhenLeavingImportFlow() { XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .contacts)) XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportSelect, destination: nil)) From 3123d7ffe8e57291d7b550b0ddcf5920fa024081 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 20:54:39 +0200 Subject: [PATCH 07/79] fix: keep the tapped ring row lookup and refresh a reused adopt profile Adopting a Pubky Ring identity now stops only the other rows' lookups, so the tapped row's result can still land in time to be reused, and a failed adopt reloads the rows instead of leaving bare keys. Adoption looks the row profile up by the normalized pubky, and when it reuses one it refreshes the profile in the background without holding up navigation or deciding profile setup. The ring lookup tests now fail at a deadline instead of hanging when a request is never answered. --- Bitkit/Managers/PubkyProfileManager.swift | 19 ++- Bitkit/Views/Profile/PubkyChoiceView.swift | 21 ++- BitkitTests/PubkyProfileManagerTests.swift | 155 +++++++++++++++++++-- 3 files changed, 179 insertions(+), 16 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 25cf75781..bf5470e70 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -330,8 +330,9 @@ class PubkyProfileManager: ObservableObject { // Read only after sign-in: when lookups are contended, the tapped row's result lands after the tap. Only a found // row profile for this key is reused; anything else takes the fetch that decides profile setup. - var adoptedProfile = ringIdentityProfiles[adoptedPublicKey] - if !PubkyPublicKeyFormat.matches(adoptedProfile?.publicKey, adoptedPublicKey) { + var adoptedProfile = PubkyPublicKeyFormat.normalized(adoptedPublicKey).flatMap { ringIdentityProfiles[$0] } + let reusesRowProfile = PubkyPublicKeyFormat.matches(adoptedProfile?.publicKey, adoptedPublicKey) + if !reusesRowProfile { adoptedProfile = await fetchRemoteProfile(publicKey: adoptedPublicKey) } clearRingIdentityProfiles() @@ -340,6 +341,11 @@ class PubkyProfileManager: ObservableObject { if let adoptedProfile { commitProfile(adoptedProfile) } + if reusesRowProfile { + // A row profile can be from earlier in the session. The refresh runs behind navigation, never decides profile + // setup, and is dropped if a newer profile write lands first. + Task { await loadProfile() } + } return adoptedProfile } @@ -708,6 +714,15 @@ class PubkyProfileManager: ObservableObject { ringIdentityMisses.removeAll() } + /// Stops every row lookup but `pubky`'s, whose result adoption can still reuse if it lands before sign-in finishes. + /// A stopped lookup records no miss, so the next load looks that row up again. + func cancelRingIdentityLookups(except pubky: String) { + let keptKey = PubkyPublicKeyFormat.normalized(pubky) + for (key, lookup) in ringIdentityLookups where key != keptKey { + lookup.task.cancel() + } + } + private func needsRingIdentityLookup(_ key: String) -> Bool { guard ringIdentityProfiles[key] == nil, !ringIdentityMisses.contains(key) else { return false } // A cancelled lookup records no miss when it fails, so it cannot stand in for a live one. diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 7dde741ea..962776614 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -25,6 +25,23 @@ struct PubkyChoiceView: View { !hasRingIdentities } + /// Only the other rows' lookups stop, since they would compete with sign-in while the tapped row's can still land in + /// time to be reused. A failed adopt reloads the rows so none is left on a bare key. + @MainActor + static func adoptRingIdentity( + _ pubky: String, + pubkyProfile: PubkyProfileManager, + reloadRows: () -> Void + ) async throws -> PubkyProfile? { + pubkyProfile.cancelRingIdentityLookups(except: pubky) + do { + return try await pubkyProfile.adoptRingIdentity(pubky: pubky) + } catch { + reloadRows() + throw error + } + } + var body: some View { ZStack { backgroundIllustrations @@ -127,8 +144,6 @@ struct PubkyChoiceView: View { private func startAdopting(_ pubky: String) { guard adoptingPubky == nil else { return } - // Row lookups still queued would only compete with sign-in. - cancelLoad() adoptingPubky = pubky Task { await adopt(pubky) } } @@ -137,7 +152,7 @@ struct PubkyChoiceView: View { defer { adoptingPubky = nil } do { - guard let adopted = try await pubkyProfile.adoptRingIdentity(pubky: pubky) else { + guard let adopted = try await Self.adoptRingIdentity(pubky, pubkyProfile: pubkyProfile, reloadRows: reloadIdentities) else { navigation.navigate(.createProfile) return } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 06a8b9b45..d3dc16495 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -553,11 +553,11 @@ final class PubkyProfileManagerTests: XCTestCase { let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + // Held from here on, so adoption only returns if it reads nothing remote itself. + await stub.setHoldsRequests(true) let adopted = await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) - let requests = await stub.requests - XCTAssertEqual(requests.count, 2, "Adoption reuses the row's profile instead of fetching it again") XCTAssertEqual(adopted?.name, "Alice") XCTAssertEqual(manager.profile?.name, "Alice") XCTAssertEqual(manager.cachedName, "Alice") @@ -565,9 +565,89 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertEqual(manager.authState, .authenticated) XCTAssertFalse(manager.isProfileSetupPending) XCTAssertTrue(manager.ringIdentityProfiles.isEmpty) + + await stub.waitForRequests(3) + let requests = await stub.requests + XCTAssertEqual(requests.count, 3, "Adoption reuses the row's profile; only the background refresh reads it again") + XCTAssertEqual(requests.last, ringKeyA) + await stub.release(request: 2) + await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } } } + @MainActor + func testRingAdoptionReusesTheRowProfileWhateverFormTheAdoptedKeyTakes() async { + await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA]) + await stub.setHoldsRequests(true) + + let adopted = await manager.completeRingAdoptionForTesting(publicKey: bareRingKeyA) + + XCTAssertEqual(adopted?.name, "Alice", "Rows are cached under the normalized pubky") + XCTAssertFalse(manager.isProfileSetupPending) + await stub.waitForRequests(2) + await stub.release(request: 1) + await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } + let requests = await stub.requests + XCTAssertEqual(requests, [ringKeyA, bareRingKeyA], "Only the background refresh reads again") + } + } + + @MainActor + func testRingAdoptionRefreshesAReusedRowProfileWithoutDecidingProfileSetup() async { + await withRestoredProfileDefaults { + for refreshedProfile in [makeProfile(publicKey: ringKeyA, name: "Alice Renamed"), nil] { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA]) + await stub.setProfile(refreshedProfile, for: ringKeyA) + + _ = await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + await stub.waitForRequests(2) + await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } + + let expectedName = refreshedProfile?.name ?? "Alice" + XCTAssertEqual(manager.profile?.name, expectedName, "A failed refresh keeps the adopted row profile") + XCTAssertEqual(manager.cachedName, expectedName) + XCTAssertFalse(manager.isProfileSetupPending, "Only the adoption decides profile setup") + } + } + } + + @MainActor + func testAdoptingStopsOnlyTheOtherRowLookupsAndAFailedAdoptReloadsTheRows() async { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) } + await stub.waitForRequests(2) + + var rowReloads = 0 + do { + // No Pubky Ring secret is stored under test, so signing in fails. + _ = try await PubkyChoiceView.adoptRingIdentity(bareRingKeyA, pubkyProfile: manager) { rowReloads += 1 } + XCTFail("Expected adopting without a Pubky Ring key to fail") + } catch {} + XCTAssertEqual(rowReloads, 1, "A failed adopt reloads the rows") + + await stub.release(request: 0) + await stub.release(request: 1) + await rows.value + let requests = await stub.requests + let cancelledRequests = await stub.cancelledRequests + XCTAssertEqual(cancelledRequests, Set(requests.indices.filter { requests[$0] == ringKeyB }), "Only the other row's lookup stops") + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice", "The tapped row's lookup still lands") + + await stub.setHoldsRequests(false) + await stub.setProfile(makeProfile(publicKey: ringKeyB, name: "Bob"), for: ringKeyB) + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + let reloadRequests = await stub.requests + XCTAssertEqual(reloadRequests.count, 3) + XCTAssertEqual(reloadRequests.last, ringKeyB, "The stopped row recorded no miss, so reloading looks it up again") + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyB]?.name, "Bob") + } + @MainActor func testRingAdoptionAfterARowMissStillFetchesTheProfile() async { await withRestoredProfileDefaults { @@ -1216,6 +1296,24 @@ final class PubkyProfileManagerTests: XCTestCase { await body() } + /// Fails the test instead of hanging it when `condition` does not hold before the deadline. + @MainActor + private func waitUntil( + _ description: String, + file: StaticString = #filePath, + line: UInt = #line, + _ condition: () -> Bool + ) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while !condition() { + guard ContinuousClock.now < deadline else { + XCTFail("Timed out waiting until \(description)", file: file, line: line) + return + } + try? await Task.sleep(for: .milliseconds(5)) + } + } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { let suiteName = "PubkyProfileManagerTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -1277,13 +1375,18 @@ private let ringKeyB = "pubky\(bareRingKeyB)" private let ringKeyC = "pubky\(bareRingKeyC)" /// Stands in for the remote profile lookup. While holding, each request waits for its own `release(request:)` and then -/// answers from the profiles set at that moment; a key with no profile fails as not found. +/// answers from the profiles set at that moment; a key with no profile fails as not found. A request held, or a wait for +/// requests, that outlasts the deadline fails the test and moves on instead of hanging the suite. private actor RemoteProfileStub { + private static let deadline: Duration = .seconds(5) + private(set) var requests: [String] = [] + /// Indexes of the requests whose task was cancelled by the time they answered. + private(set) var cancelledRequests: Set = [] private var profiles: [String: PubkyProfile] private var isHolding: Bool private var heldRequests: [Int: CheckedContinuation] = [:] - private var requestWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var requestWaiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] init(profiles: [String: PubkyProfile] = [:], holdsRequests: Bool = false) { self.profiles = profiles @@ -1293,23 +1396,34 @@ private actor RemoteProfileStub { func resolve(_ key: String) async throws -> PubkyProfile { let index = requests.count requests.append(key) - let satisfiedWaiters = requestWaiters.filter { $0.count <= requests.count } - requestWaiters.removeAll { $0.count <= requests.count } - satisfiedWaiters.forEach { $0.continuation.resume() } + for (id, waiter) in requestWaiters where waiter.count <= requests.count { + requestWaiters[id] = nil + waiter.continuation.resume() + } if isHolding { - await withCheckedContinuation { heldRequests[index] = $0 } + await withCheckedContinuation { continuation in + heldRequests[index] = continuation + failAfterDeadline { await $0.expireHeldRequest(index) } + } + } + if Task.isCancelled { + cancelledRequests.insert(index) } guard let profile = profiles[key] else { throw PubkyServiceError.profileNotFound } return profile } - func waitForRequests(_ count: Int) async { + func waitForRequests(_ count: Int, file: StaticString = #filePath, line: UInt = #line) async { guard requests.count < count else { return } - await withCheckedContinuation { requestWaiters.append((count, $0)) } + let id = UUID() + await withCheckedContinuation { continuation in + requestWaiters[id] = (count, continuation) + failAfterDeadline { await $0.expireRequestWaiter(id, file: file, line: line) } + } } - func setProfile(_ profile: PubkyProfile, for key: String) { + func setProfile(_ profile: PubkyProfile?, for key: String) { profiles[key] = profile } @@ -1320,6 +1434,25 @@ private actor RemoteProfileStub { func release(request index: Int) { heldRequests.removeValue(forKey: index)?.resume() } + + private func failAfterDeadline(_ expire: @escaping @Sendable (RemoteProfileStub) async -> Void) { + Task { + try? await Task.sleep(for: Self.deadline) + await expire(self) + } + } + + private func expireHeldRequest(_ index: Int) { + guard let continuation = heldRequests.removeValue(forKey: index) else { return } + XCTFail("Request \(index) was never released") + continuation.resume() + } + + private func expireRequestWaiter(_ id: UUID, file: StaticString, line: UInt) { + guard let waiter = requestWaiters.removeValue(forKey: id) else { return } + XCTFail("Timed out waiting for \(waiter.count) requests; saw \(requests.count)", file: file, line: line) + waiter.continuation.resume() + } } @MainActor From a3c8d770b7515734964958f599e521ef216da4d5 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 21:01:26 +0200 Subject: [PATCH 08/79] test: add pubky profile loading journeys --- journeys/README.md | 2 + journeys/pubky-profile/README.md | 68 +++++++++++++++++++ .../pubky-profile/cached-profile-header.xml | 29 ++++++++ journeys/pubky-profile/ring-choice-rows.xml | 35 ++++++++++ 4 files changed, 134 insertions(+) create mode 100644 journeys/pubky-profile/README.md create mode 100644 journeys/pubky-profile/cached-profile-header.xml create mode 100644 journeys/pubky-profile/ring-choice-rows.xml diff --git a/journeys/README.md b/journeys/README.md index 7678c3fdf..13725f77a 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -124,6 +124,8 @@ Known naming differences: | Send max | `SendAmountMax` | *(no button — tap `AvailableAmount`)* | | External amount available | — | `ExternalAmountAvailable` | | Payment Request details screen | `PaymentRequestDetailsScreen` | `PaymentRequestDetailScreen` | +| Pubky Ring choice row | `PubkyChoiceIdentity` (the same tag on every row) | `PubkyChoiceRing_` (bare z32 key) | +| Cached profile header while the profile loads | — | `ProfileCachedHeader`, with the name `ProfileCachedName` | `SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix but appends the billing period (`-one-time` for a one-off), because every recurring payment of one diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md new file mode 100644 index 000000000..334c39941 --- /dev/null +++ b/journeys/pubky-profile/README.md @@ -0,0 +1,68 @@ +# Pubky profile loading + +This suite covers what Profile and the Pubky Ring choice screen show while their profiles are still +loading from the network. Both journeys are iOS-only: Android has no journey for either screen, and +it behaves differently in both places (see [iOS vs Android](#ios-vs-android)). + +- `cached-profile-header.xml` opens Profile straight after a relaunch, while the signed-in profile is + still loading. Profile shows the name cached from the last load, read-only, then swaps in the full + profile in place. +- `ring-choice-rows.xml` opens the choice screen with Pubky Ring identities. The rows show straight + away under truncated keys and fill in names as each lookup finishes. Tapping a row adopts that + identity: only the tapped row shows a spinner, and every row is disabled until adoption finishes. + +## Preconditions + +Both journeys need an onboarded E2E Bitkit build with Paykit UI enabled. Use the test simulator UDID +for ``. + +**Cached profile header.** A Bitkit-generated Pubky identity whose profile has a name, as in the +Pubky identity row of the Capabilities table in [`journeys/README.md`](../README.md). Profile must +have loaded fully once on this build, so the cache records which key the name belongs to. A cache +written before the owner was recorded, or for another key, shows the plain spinner instead of the +header. + +**Ring choice rows.** Pubky Ring is **not** in the Capabilities table, so the reviewer supplies it: + +- Pubky Ring installed on the same simulator or device and signed by the same Apple team, so it + shares Bitkit's `pubky.shared` keychain group. Bitkit has no way to seed Ring records itself. +- At least two identities in Pubky Ring, at least one with a profile name published where this + Bitkit build resolves profiles. +- No Pubky identity in Bitkit, and a cold launch, so no Ring row profile is remembered from earlier + in the session. + +Without Pubky Ring, run the steps as a manual test and name the missing capability in the PR. +Tapping a row signs in with that Ring identity, so use identities you are happy to adopt. + +## Timing + +Both loading states can finish faster than a snapshot round trip on a warm network. Each journey +reports that ("already loaded", "already resolved" or "already adopted") rather than failing. The +cached-header journey repeats the relaunch once, and the ring journey continues. + +Opening Profile in the first few seconds after launch can show a bare spinner, with no "Profile" +title, before the cached header. That spinner is the Pubky initialization wait in `MainNavView`. The +cached header needs initialization to finish, so wait for it to clear and do not report it as the +header failing. A spinner under the "Profile" title with no name is different: it means no cached +header showed. + +## iOS vs Android + +- **Profile while loading.** Android shows only a spinner until the profile loads. iOS shows the + cached name and avatar read-only under `ProfileCachedHeader`, with no edit, copy, share, QR code + or tag controls, and then the full profile under `ProfileViewName`. +- **Ring choice rows.** Android's `PubkyChoiceScreen` replaces the rows with one loading line while + it loads and while it adopts, and tags every row `PubkyChoiceIdentity`. iOS keeps the rows up, + spins only the tapped row, and tags each row `PubkyChoiceRing_`, using the bare z32 key + without the `pubky` prefix. + +## Identifiers used + +- Home: the header profile button `ProfileButton`; Profile intro `ProfileIntro` with Continue + `ProfileIntro-button`. +- Profile: cached header `ProfileCachedHeader` and its name `ProfileCachedName`; full profile name + `ProfileViewName`; actions `ProfileEdit`, `ProfileCopy`, `ProfileShare`, `ProfileQRCode` and + `ProfileAddTag`. +- Choice screen: Ring rows `PubkyChoiceRing_` and the create option `PubkyChoiceCreate`. +- After adopting: the contact import overview `ContactImportOverviewProfile`, or Pay Contacts + `PayContactsContinue`. diff --git a/journeys/pubky-profile/cached-profile-header.xml b/journeys/pubky-profile/cached-profile-header.xml new file mode 100644 index 000000000..6e4c7f022 --- /dev/null +++ b/journeys/pubky-profile/cached-profile-header.xml @@ -0,0 +1,29 @@ + + + Verifies that opening Profile straight after a relaunch, while the signed-in profile is still + loading, shows the cached name read-only. The edit, copy, share, QR code and tag controls are + absent. The full profile then replaces the cached header in place. + + Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky + identity whose profile has a name. Profile must have loaded fully once on this build, so the + cache records which key the name belongs to. Use the test simulator UDID for <UDID>. + + iOS-only, with no Android counterpart. Android shows a spinner until the profile loads. A warm + network can finish the load before the first snapshot, so an "already loaded" result repeats + the relaunch once instead of failing. See README.md. + + + Tap the header profile button (id "ProfileButton") + Wait for the full profile name (id "ProfileViewName", predicate exists) and note the name it shows + Run `xcrun simctl terminate <UDID> to.bitkit` + Run `xcrun simctl launch <UDID> to.bitkit` + If the PIN screen appears, unlock the wallet + As soon as the home screen shows, tap the header profile button (id "ProfileButton") + If a bare spinner with no "Profile" title shows first, wait for it to clear. That is the Pubky initialization wait, which has to finish before the cached header can show. A spinner under the "Profile" title with no name is not that wait: no cached header showed, so report it + Verify the cached profile header (id "ProfileCachedHeader") is visible and its name (id "ProfileCachedName") matches the name noted before the relaunch. If the full profile name (id "ProfileViewName") is already showing, the load finished first: report "already loaded" and repeat from the terminate step once rather than failing + Verify the cached header is read-only: edit (id "ProfileEdit"), copy (id "ProfileCopy"), share (id "ProfileShare"), the QR code (id "ProfileQRCode") and add tag (id "ProfileAddTag") are all absent + Wait for the full profile name (id "ProfileViewName", predicate exists) + Verify the cached profile header (id "ProfileCachedHeader") is gone and the full profile name (id "ProfileViewName") matches the cached name + Verify edit (id "ProfileEdit") and the QR code (id "ProfileQRCode") are visible + + diff --git a/journeys/pubky-profile/ring-choice-rows.xml b/journeys/pubky-profile/ring-choice-rows.xml new file mode 100644 index 000000000..6f0d1a89d --- /dev/null +++ b/journeys/pubky-profile/ring-choice-rows.xml @@ -0,0 +1,35 @@ + + + Verifies that the Pubky Ring choice screen lists every Ring identity straight away under its + truncated key and fills each name in as its profile lookup finishes. It also verifies that + tapping a row shows a spinner on that row only and disables every row until adoption finishes. + + Precondition, outside the Capabilities table in journeys/README.md: Pubky Ring installed on the + same simulator or device and signed by the same Apple team, so it shares Bitkit's pubky.shared + keychain group. It holds at least two identities, at least one with a profile name published + where this Bitkit build resolves profiles. Without Pubky Ring, run these steps as a manual test + and name the missing capability in the PR. Bitkit is an onboarded E2E build with Paykit UI + enabled and no Pubky identity. Use the test simulator UDID for <UDID>. + <ring-pubky> is a Ring identity's bare z32 key without the "pubky" prefix, and + <named-ring-pubky> is the one with a published name. Tapping that row signs in with it. + + iOS-only, with no Android counterpart. Android shows one loading line in place of the rows + while it loads and while it adopts, and tags every row "PubkyChoiceIdentity". iOS keeps the + rows up and tags each one "PubkyChoiceRing_<pubky>". See README.md. + + + Run `xcrun simctl terminate <UDID> to.bitkit`, then `xcrun simctl launch <UDID> to.bitkit`, so no Ring row profile is remembered from earlier in the session + If the PIN screen appears, unlock the wallet + Tap the header profile button (id "ProfileButton"). If nothing happens, Pubky is still initializing: wait a moment and tap it again + If the Profile intro (id "ProfileIntro") appears, tap Continue (id "ProfileIntro-button") + Verify the choice screen shows one row per Ring identity (id "PubkyChoiceRing_<ring-pubky>") and no create option (id "PubkyChoiceCreate") + Verify every row is already tappable and captioned with its truncated key in capitals: the key's first four and last four characters around "...". A row whose name has not arrived also shows that truncated key as its title. If every name is already showing, the lookups finished first: report "already resolved" rather than failing + Wait for the row for the named identity (id "PubkyChoiceRing_<named-ring-pubky>") to show its published name as its title + Verify that row keeps its truncated key as its caption, and any row whose identity has no published name still shows its truncated key as its title + Tap the row for the named identity (id "PubkyChoiceRing_<named-ring-pubky>") + Straight away, capture a snapshot and a screenshot before adoption finishes + Verify from the snapshot that every Ring row is disabled while the identity is adopted. A disabled button drops out of the snapshot's Targets list, so the signal is the "PubkyChoiceRing_" rows missing from Targets while the choice screen is still showing. If adoption finished before the capture, report "already adopted" rather than failing + Verify from the screenshot that only the tapped row shows a spinner in place of its key icon, while every other row keeps its key icon and its name or truncated key. The spinner has no identifier, so a snapshot cannot settle this. If adoption finished before the capture, report "already adopted" rather than failing + Verify the choice screen gives way to the contact import overview (id "ContactImportOverviewProfile") or Pay Contacts (id "PayContactsContinue") + + From df8b8b0929c4f5c76113a3d6adcef2815cba26a8 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 21:36:33 +0200 Subject: [PATCH 09/79] docs(changelog): add fragment for pubky profile loading fix --- changelog.d/next/854.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/854.fixed.md diff --git a/changelog.d/next/854.fixed.md b/changelog.d/next/854.fixed.md new file mode 100644 index 000000000..40e6077c5 --- /dev/null +++ b/changelog.d/next/854.fixed.md @@ -0,0 +1 @@ +Pubky profiles load faster: Pubky Ring identities appear at once, adopting one no longer waits on other lookups, and the profile screen shows your cached name and avatar while it loads. From e2d3a4a0b22c192cf9c354230a5c465e6ae79041 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 22:28:44 +0200 Subject: [PATCH 10/79] fix: keep the ring choice screen during adoption and reuse the in-flight row lookup Adopting a Pubky Ring key writes its reference before sign-in finishes, so a row result landing mid-sign-in swapped the profile destination to an empty profile. The destination now keeps the choice screen while adoption is in progress, and returns to it when adoption fails. Adoption awaits the tapped row's lookup that is still in flight instead of fetching the same key again, and automatic session recovery while signed out no longer discards the choice rows and their lookups. --- Bitkit/Managers/PubkyProfileManager.swift | 19 ++- Bitkit/Views/Profile/ProfileView.swift | 19 ++- BitkitTests/PubkyProfileManagerTests.swift | 165 +++++++++++++++++++++ 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 14c63a16a..565dd8225 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -54,6 +54,9 @@ class PubkyProfileManager: ObservableObject { @Published private(set) var isProfileSetupPending: Bool /// Public profiles found for the Pubky Ring rows on the choice screen, keyed by normalized pubky. Display-only. @Published private(set) var ringIdentityProfiles: [String: PubkyProfile] = [:] + /// Covers a Ring adoption from before its reference is written, which already makes `hasExistingIdentity` true, until + /// it succeeds, fails or is cancelled. + @Published private(set) var isAdoptingRingIdentity = false private var isSignupInFlight = false private var initializationTask: Task? @@ -427,6 +430,7 @@ class PubkyProfileManager: ObservableObject { Self.beginSessionMutation() defer { Self.isRingAdoptionInFlight = false + isAdoptingRingIdentity = false Self.endSessionMutation() } let sourceApp = SharedPubkyKeychain.ringSourceApp @@ -435,6 +439,7 @@ class PubkyProfileManager: ObservableObject { } let previousAdoptedIdentity = AdoptedPubkyReference.current + isAdoptingRingIdentity = true AdoptedPubkyReference.current = (sourceApp, pubky) let adoptedPublicKey: String do { @@ -469,7 +474,13 @@ class PubkyProfileManager: ObservableObject { let adoptionRevision = Self.sessionRevision // Read only after sign-in: when lookups are contended, the tapped row's result lands after the tap. Only a found // row profile for this key is reused; anything else takes the fetch that decides profile setup. - var adoptedProfile = PubkyPublicKeyFormat.normalized(adoptedPublicKey).flatMap { ringIdentityProfiles[$0] } + let rowKey = PubkyPublicKeyFormat.normalized(adoptedPublicKey) + var adoptedProfile = rowKey.flatMap { ringIdentityProfiles[$0] } + if adoptedProfile == nil, let rowKey, let lookup = ringIdentityLookups[rowKey], !lookup.task.isCancelled { + // The row's lookup is already ahead of a fresh fetch for the same key. + await lookup.task.value + adoptedProfile = ringIdentityProfiles[rowKey] + } let reusesRowProfile = PubkyPublicKeyFormat.matches(adoptedProfile?.publicKey, adoptedPublicKey) if !reusesRowProfile { adoptedProfile = if let fetchProfile { @@ -1158,6 +1169,8 @@ class PubkyProfileManager: ObservableObject { } private func clearAuthenticatedState(clearCachedProfile: Bool = true) { + // Automatic recovery also lands here while nothing is signed in, which must not discard the choice rows. + let wasAuthenticated = isAuthenticated invalidateProfileLoads() publicKey = nil profile = nil @@ -1165,7 +1178,9 @@ class PubkyProfileManager: ObservableObject { if clearCachedProfile { clearCachedProfileMetadata() } - clearRingIdentityProfiles() + if wasAuthenticated { + clearRingIdentityProfiles() + } } private func activeSessionSecret() throws -> String { diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index b8a16da81..e0980be7a 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -5,12 +5,25 @@ struct ProfileDestinationView: View { let hasSeenIntro: Bool - var body: some View { + /// Adopting a Pubky Ring key counts as an existing identity before sign-in finishes, so the choice screen stays up + /// until adoption settles instead of swapping to an empty profile mid-sign-in. + static func destination(for pubkyProfile: PubkyProfileManager, hasSeenIntro: Bool) -> Route { + if pubkyProfile.isAdoptingRingIdentity { + return .pubkyChoice + } if pubkyProfile.hasExistingIdentity { + return .profile + } + return hasSeenIntro ? .pubkyChoice : .profileIntro + } + + var body: some View { + switch Self.destination(for: pubkyProfile, hasSeenIntro: hasSeenIntro) { + case .profile: ProfileView() - } else if hasSeenIntro { + case .pubkyChoice: PubkyChoiceView() - } else { + default: ProfileIntroView() } } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 9f009d489..51ed0b803 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1156,6 +1156,7 @@ final class PubkyProfileManagerTests: XCTestCase { let inFlight = Task { await manager.loadRingIdentityProfiles([bareRingKeyC]) } await stub.waitForRequests(3) + manager.publicKey = ringKeyA manager.clearAuthenticatedStateForTesting() await stub.setProfile(makeProfile(publicKey: ringKeyC, name: "Carol"), for: ringKeyC) await stub.release(request: 2) @@ -1169,6 +1170,39 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testAutomaticRecoveryWhileSignedOutKeepsRingIdentityProfilesAndLookups() async { + let savedReference = AdoptedPubkyReference.current + defer { AdoptedPubkyReference.current = savedReference } + AdoptedPubkyReference.current = nil + await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + await stub.setHoldsRequests(true) + let inFlight = Task { await manager.loadRingIdentityProfiles([bareRingKeyC]) } + await stub.waitForRequests(3) + + for result in [PubkyProfileManager.SessionInitializationResult.noSession, .restorationFailed] { + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { result } + } + XCTAssertNil(manager.publicKey) + + await stub.setProfile(makeProfile(publicKey: ringKeyC, name: "Carol"), for: ringKeyC) + await stub.release(request: 2) + await inFlight.value + let cancelledRequests = await stub.cancelledRequests + XCTAssertTrue(cancelledRequests.isEmpty, "Recovery while signed out stops no row lookup") + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyC]?.name, "Carol") + + await stub.setHoldsRequests(false) + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB, bareRingKeyC]) + let requests = await stub.requests + XCTAssertEqual(requests.count, 3, "Neither the found profiles nor the miss are looked up again") + } + } + @MainActor func testRingAdoptionReusesTheFoundRowProfileAndClearsTheRows() async throws { try await withRestoredProfileDefaults { @@ -1320,6 +1354,137 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testRingAdoptionWaitsForTheTappedRowLookupInsteadOfFetchingAgain() async throws { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(1) + + let adoption = Task { try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) } + await waitUntil("adoption reaches the row lookup") { manager.publicKey == ringKeyA } + await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice"), for: ringKeyA) + await stub.release(request: 0) + await rows.value + // A fetch of adoption's own would answer with this instead. + await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice Refetched"), for: ringKeyA) + + let adopted = try await adoption.value + XCTAssertEqual(adopted?.name, "Alice", "Adoption reuses the tapped row's lookup that was still in flight") + XCTAssertFalse(manager.isProfileSetupPending) + + await stub.waitForRequests(2) + await stub.release(request: 1) + await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } + let requests = await stub.requests + XCTAssertEqual(requests, [ringKeyA, ringKeyA], "Only the background refresh reads again") + } + } + + @MainActor + func testRingAdoptionFetchesWhenTheTappedRowLookupInFlightMisses() async throws { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(1) + + let adoption = Task { try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) } + await waitUntil("adoption reaches the row lookup") { manager.publicKey == ringKeyA } + await stub.release(request: 0) + await rows.value + // A row miss can mean offline, so the fresh fetch still decides profile setup. + await stub.waitForRequests(2) + await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice"), for: ringKeyA) + await stub.release(request: 1) + + let adopted = try await adoption.value + XCTAssertEqual(adopted?.name, "Alice") + XCTAssertFalse(manager.isProfileSetupPending) + let requests = await stub.requests + XCTAssertEqual(requests, [ringKeyA, ringKeyA]) + } + } + + @MainActor + func testRingAdoptionDoesNotWaitForAStoppedRowLookup() async throws { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(1) + rows.cancel() + + let adoption = Task { try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) } + await stub.waitForRequests(2) + await stub.release(request: 1) + let adopted = try await adoption.value + XCTAssertEqual(adopted?.name, "Alice") + + await stub.release(request: 0) + await rows.value + } + } + + @MainActor + func testProfileDestinationKeepsTheChoiceScreenWhileARingAdoptionSignsIn() async throws { + let savedReference = AdoptedPubkyReference.current + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] + let savedCredentials = try keys.map { try Keychain.load(key: $0) } + defer { + AdoptedPubkyReference.current = savedReference + for (key, value) in zip(keys, savedCredentials) { + if let value { try? Keychain.upsert(key: key, data: value) } + else { try? Keychain.delete(key: key) } + } + } + try await withRestoredProfileDefaults { + for key in keys { + try Keychain.delete(key: key) + } + AdoptedPubkyReference.current = nil + UserDefaults.standard.removeObject(forKey: "pubky_profile_name") + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(1) + XCTAssertEqual(ProfileDestinationView.destination(for: manager, hasSeenIntro: true), .pubkyChoice) + + let signInStarted = expectation(description: "Ring sign-in started") + let (signInGate, openSignIn) = AsyncStream.makeStream() + defer { openSignIn.finish() } + let adoption = Task { + try await manager.adoptRingIdentity( + pubky: bareRingKeyA, + loadSecret: { _, _ in String(repeating: "02", count: 32) }, + signIn: { _ in + signInStarted.fulfill() + for await _ in signInGate {} + throw PubkyServiceError.authFailed("sign-in failed") + } + ) + } + await fulfillment(of: [signInStarted], timeout: 2) + await stub.release(request: 0) + await rows.value + + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice", "The row lookup lands while sign-in is suspended") + XCTAssertTrue(manager.hasExistingIdentity, "The adopted reference is written before sign-in finishes") + XCTAssertTrue(manager.isAdoptingRingIdentity) + XCTAssertEqual(ProfileDestinationView.destination(for: manager, hasSeenIntro: true), .pubkyChoice) + + openSignIn.finish() + await XCTAssertThrowsErrorAsync { try await adoption.value } + XCTAssertFalse(manager.isAdoptingRingIdentity) + XCTAssertEqual( + ProfileDestinationView.destination(for: manager, hasSeenIntro: true), + .pubkyChoice, + "A failed adopt returns to the choice screen" + ) + } + } + // MARK: - HomegateResponse Decoding private typealias HomegateResponse = PubkyProfileManager.HomegateResponse From 44b346098a12564516c4801e20c69306532fd896 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 22:33:53 +0200 Subject: [PATCH 11/79] fix: gate disk-promoted avatars on the cache generation and retry contact lookups on tap A disk hit promoted to the memory cache skipped the clear generation check, so a lookup that raced a sign-out could put the previous identity's avatar back in memory. The generation is now read once before the lookup and required for every memory store. The contact detail Retry button retries a transient failure once, and activation is covered returning while the identity republish is still running. --- Bitkit/Components/PubkyImage.swift | 13 ++-- Bitkit/Views/Contacts/ContactDetailView.swift | 2 +- BitkitTests/PaykitSdkClientConfigTests.swift | 64 ++++++++++++++++++- BitkitTests/PubkyImageCacheTests.swift | 27 +++++++- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/Bitkit/Components/PubkyImage.swift b/Bitkit/Components/PubkyImage.swift index d47a7f565..696b09185 100644 --- a/Bitkit/Components/PubkyImage.swift +++ b/Bitkit/Components/PubkyImage.swift @@ -68,11 +68,11 @@ struct PubkyImage: View { /// All heavy work (disk cache, network/FFI) runs off the main actor. private nonisolated static func loadImageOffMain(uri: String) async throws -> UIImage { - if let cached = await PubkyImageCache.shared.image(for: uri) { + let cacheGeneration = PubkyImageCache.shared.generation + if let cached = await PubkyImageCache.shared.image(for: uri, generation: cacheGeneration) { return cached } - let cacheGeneration = PubkyImageCache.shared.generation let data = try await PubkyService.fetchFile(uri: uri, maxBytes: PubkyImagePolicy.maxDownloadBytes) let blobData = try await resolveImageData(data, originalUri: uri) @@ -196,7 +196,8 @@ final class PubkyImageCache: @unchecked Sendable { } /// Full lookup (memory + disk). Disk I/O runs on a dedicated queue to avoid blocking cooperative threads. - func image(for uri: String) async -> UIImage? { + /// A disk hit is promoted to memory only while `generation` is still current, as with `store`. + func image(for uri: String, generation: UInt64) async -> UIImage? { if let memoryHit = memoryImage(for: uri) { return memoryHit } @@ -221,7 +222,7 @@ final class PubkyImageCache: @unchecked Sendable { } try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: path.path) - storeInMemory(diskImage, for: uri) + storeInMemory(diskImage, for: uri, generation: generation) continuation.resume(returning: diskImage) } } @@ -283,13 +284,13 @@ final class PubkyImageCache: @unchecked Sendable { return cgImage.bytesPerRow * cgImage.height } - private func storeInMemory(_ image: UIImage, for uri: String, generation: UInt64? = nil) { + private func storeInMemory(_ image: UIImage, for uri: String, generation: UInt64) { let cost = Self.memoryCost(of: image) memoryLock.lock() defer { memoryLock.unlock() } - if let generation, generation != clearGeneration { return } + guard generation == clearGeneration else { return } if let replaced = memoryCache.removeValue(forKey: uri) { memoryCost -= replaced.cost } diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 6127811f8..99af0e0b3 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -314,7 +314,7 @@ struct ContactDetailView: View { if let contact = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) { profile = contact.profile - } else if let fetched = await contactsManager.fetchContactProfile(publicKey: publicKey) { + } else if let fetched = await contactsManager.fetchContactProfile(publicKey: publicKey, retryTransient: true) { profile = fetched } } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index dd63c009b..505b39a76 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -185,6 +185,62 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } + func testActivationReturnsWhileIdentityRepublishIsStillRunning() async throws { + let credentialKeys: [KeychainEntryType] = [ + .paykitSdkState, .paykitSession, .pubkySecretKey, .paykitReceiverNoiseSecretKey, + .bip39Mnemonic(index: 0), .bip39Passphrase(index: 0), + ] + let savedCredentials = try credentialKeys.map { try Keychain.load(key: $0) } + defer { + for (key, data) in zip(credentialKeys, savedCredentials) { + if let data { + try? Keychain.upsert(key: key, data: data) + } else { + try? Keychain.delete(key: key) + } + } + } + let mnemonic = Array(repeating: "abandon", count: 11).joined(separator: " ") + " about" + try Keychain.upsert(key: .bip39Mnemonic(index: 0), data: Data(mnemonic.utf8)) + try Keychain.delete(key: .bip39Passphrase(index: 0)) + let noiseBytes = try PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( + mnemonic: mnemonic, passphrase: nil, network: Env.networkName, receiverPath: PaykitReceiverPath.wallet + ) + try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: noiseBytes) + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let republishStarted = expectation(description: "Republish started") + let republishFinished = expectation(description: "Republish finished") + let activationReturned = expectation(description: "Activation returned") + let gate = AsyncStream.makeStream() + let bootstrap = CacheActivationBootstrap(noPointer: .init()) + bootstrap.republishOperation = { + republishStarted.fulfill() + for await _ in gate.stream { + break + } + republishFinished.fulfill() + return true + } + let sdk = CacheActivationSdk(noPointer: .init()) + sdk.previousKey = publicKey + let service = PaykitSdkService(sdkFactory: { sdk }) { _, _ in bootstrap } + let session = CacheActivationSession(noPointer: .init()) + session.noiseBytes = noiseBytes + let result = PubkySessionBootstrapResult(sessionAccess: session, publicKey: publicKey) + + let activation = Task { + try await service.activateRegisteredIdentity(result) + activationReturned.fulfill() + } + // Shorter than the republish timeout, so an activation that awaited the publication would miss it. + await fulfillment(of: [activationReturned, republishStarted], timeout: 2) + XCTAssertEqual(bootstrap.republishedKeys, [result.publicKey]) + + gate.continuation.yield() + await fulfillment(of: [republishFinished], timeout: 1) + try await activation.value + } + func testSessionRecoveryCannotReactivateCredentialsAfterForget() async throws { let keys: [KeychainEntryType] = [ .paykitSdkState, .paykitSession, .pubkySecretKey, .paykitReceiverNoiseSecretKey, @@ -357,8 +413,12 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { } private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked Sendable { - override func republishIdentity(publicKey _: String) async throws -> Bool { - true + var republishedKeys: [String] = [] + var republishOperation: () async -> Bool = { true } + + override func republishIdentity(publicKey: String) async throws -> Bool { + republishedKeys.append(publicKey) + return await republishOperation() } } diff --git a/BitkitTests/PubkyImageCacheTests.swift b/BitkitTests/PubkyImageCacheTests.swift index dbf052932..bc64350b1 100644 --- a/BitkitTests/PubkyImageCacheTests.swift +++ b/BitkitTests/PubkyImageCacheTests.swift @@ -30,7 +30,7 @@ final class PubkyImageCacheTests: XCTestCase { XCTAssertNil(cache.memoryImage(for: uri)) XCTAssertFalse(FileManager.default.fileExists(atPath: diskPath.path)) - let diskImage = await cache.image(for: uri) + let diskImage = await cache.image(for: uri, generation: cache.generation) XCTAssertNil(diskImage) } @@ -58,6 +58,27 @@ final class PubkyImageCacheTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: stalePath.path)) } + func testDiskPromotionCapturedBeforeClearIsNotKeptInMemory() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = PubkyImageCache(diskDirectory: directory) + let uri = "pubky://previous-user/pub/bitkit.to/blobs/avatar.jpg" + let imageData = try XCTUnwrap(image(color: .red).pngData()) + let generationBeforeClear = cache.generation + + await cache.clear() + // Stands in for a disk read that started before the clear and still found the previous identity's file. + try imageData.write(to: pubkyImageDiskPath(for: uri, directory: directory)) + let staleImage = await cache.image(for: uri, generation: generationBeforeClear) + + XCTAssertNotNil(staleImage) + XCTAssertNil(cache.memoryImage(for: uri)) + + let currentImage = await cache.image(for: uri, generation: cache.generation) + XCTAssertNotNil(currentImage) + XCTAssertNotNil(cache.memoryImage(for: uri)) + } + func testDecoderDownsamplesLargeImagesBeforeCaching() throws { let format = UIGraphicsImageRendererFormat() format.scale = 1 @@ -161,7 +182,7 @@ final class PubkyImageCacheTests: XCTestCase { ) let removedFirstFile = await waitForMissingFile(at: firstPath) - let remainingImage = await cache.image(for: secondURI) + let remainingImage = await cache.image(for: secondURI, generation: cache.generation) XCTAssertTrue(removedFirstFile) XCTAssertNotNil(remainingImage) } @@ -175,7 +196,7 @@ final class PubkyImageCacheTests: XCTestCase { let cache = PubkyImageCache(diskDirectory: directory, maxFileBytes: 3, memoryCostLimit: 1024, diskByteLimit: 1024) try Data([0, 1, 2, 3]).write(to: path) - let image = await cache.image(for: uri) + let image = await cache.image(for: uri, generation: cache.generation) XCTAssertNil(image) XCTAssertFalse(FileManager.default.fileExists(atPath: path.path)) } From bc65361726b448663ffc6a5988cc0ffa6fbaafef Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Wed, 30 Sep 2026 22:40:44 +0200 Subject: [PATCH 12/79] fix: start profile setup when a reused Ring profile is gone and mirror row profiles exactly The refresh after adoption reuses a Ring row profile now treats a definitive not-found for the adopted key as a removed profile: it clears the profile and its cached metadata and marks profile setup pending, unless the identity changed or a newer profile write landed. Transport failures keep the reused profile. The choice screen's row profiles now equal the manager's cache outside an adoption and only merge while one runs. --- Bitkit/Managers/PubkyProfileManager.swift | 42 ++++++++- Bitkit/Views/Profile/PubkyChoiceView.swift | 16 +++- BitkitTests/PubkyChoiceViewTests.swift | 24 +++++ BitkitTests/PubkyProfileManagerTests.swift | 101 +++++++++++++++++---- 4 files changed, 159 insertions(+), 24 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 565dd8225..7e8039912 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -499,13 +499,49 @@ class PubkyProfileManager: ObservableObject { commitProfile(adoptedProfile) } if reusesRowProfile { - // A row profile can be from earlier in the session. The refresh runs behind navigation, never decides profile - // setup, and is dropped if a newer profile write lands first. - Task { await loadProfile() } + // A row profile can be from earlier in the session, so it is refreshed behind navigation. + let generation = profileWriteGeneration + Task { + await refreshReusedRingProfile(publicKey: adoptedPublicKey, generation: generation, adoptionRevision: adoptionRevision) + } } return adoptedProfile } + /// Only a definitive not-found undoes the reused profile, leaving the key as adopting one without a profile does so + /// routing prompts profile setup. Any other failure may just mean offline and keeps it. Dropped if the identity + /// changed or a newer profile write landed. + private func refreshReusedRingProfile(publicKey adoptedPublicKey: String, generation: Int, adoptionRevision: UUID) async { + guard !isLoadingProfile else { return } + + isLoadingProfile = true + defer { isLoadingProfile = false } + let resolve = remoteProfileResolver + func isStillCurrent() -> Bool { + adoptionRevision == Self.sessionRevision && publicKey == adoptedPublicKey && profileWriteGeneration == generation + } + + do { + let refreshedProfile = try await Task.detached { + try await resolve(adoptedPublicKey) + }.value + guard isStillCurrent() else { return } + profile = refreshedProfile + cacheProfileMetadata(refreshedProfile) + } catch PubkyServiceError.profileNotFound { + guard isStillCurrent() else { return } + Logger.info("Adopted Pubky Ring profile no longer exists, starting profile setup", context: "PubkyProfileManager") + invalidateProfileLoads() + profile = nil + if PubkyPublicKeyFormat.matches(cachedProfileOwner, adoptedPublicKey) { + clearCachedProfileMetadata() + } + setProfileSetupPending(true) + } catch { + Logger.warn("Failed to refresh the adopted Pubky Ring profile: \(error)", context: "PubkyProfileManager") + } + } + private nonisolated static func signInWithRingKey(_ secretKeyHex: String) async throws { let publicKey = try publicKeyFromSecretKey(secretKeyHex) do { diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 8ba9730bd..f5368cabc 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -25,6 +25,18 @@ struct PubkyChoiceView: View { !hasRingIdentities } + /// Equals the manager's row profiles, so a row whose lookup now finds nothing drops its old name and avatar. While an + /// adoption runs it only adds: adopting clears the manager's cache while this screen is still up, and rows must not + /// flash back to bare keys before navigation. + static func mirroredRingProfiles( + _ shown: [String: PubkyProfile], + found: [String: PubkyProfile], + isAdopting: Bool + ) -> [String: PubkyProfile] { + guard isAdopting else { return found } + return shown.merging(found) { _, latest in latest } + } + /// Only the other rows' lookups stop, since they would compete with sign-in while the tapped row's can still land in /// time to be reused. A failed adopt reloads the rows so none is left on a bare key. @MainActor @@ -76,9 +88,7 @@ struct PubkyChoiceView: View { reloadIdentities() } .onReceive(pubkyProfile.$ringIdentityProfiles) { found in - // Only ever add: adopting clears the manager's cache while this screen is still up, and rows must not - // flash back to bare keys before navigation. - ringProfiles.merge(found) { _, latest in latest } + ringProfiles = Self.mirroredRingProfiles(ringProfiles, found: found, isAdopting: pubkyProfile.isAdoptingRingIdentity) } } diff --git a/BitkitTests/PubkyChoiceViewTests.swift b/BitkitTests/PubkyChoiceViewTests.swift index 6dc571f4a..dffd1f027 100644 --- a/BitkitTests/PubkyChoiceViewTests.swift +++ b/BitkitTests/PubkyChoiceViewTests.swift @@ -14,4 +14,28 @@ final class PubkyChoiceViewTests: XCTestCase { XCTAssertFalse(PubkyChoiceView.showsCreateCard(hasRingIdentities: true)) XCTAssertTrue(PubkyChoiceView.showsCreateCard(hasRingIdentities: false)) } + + func testMirroredRingProfilesEqualTheFoundProfilesWhenNotAdopting() { + let shown = ["alice": profile("alice", name: "Alice"), "bob": profile("bob", name: "Bob")] + let found = ["alice": profile("alice", name: "Alice Renamed")] + + let mirrored = PubkyChoiceView.mirroredRingProfiles(shown, found: found, isAdopting: false) + + XCTAssertEqual(mirrored.mapValues(\.name), ["alice": "Alice Renamed"], "A row whose lookup now finds nothing drops its old profile") + } + + func testMirroredRingProfilesOnlyAddWhileAdopting() { + let shown = ["alice": profile("alice", name: "Alice"), "bob": profile("bob", name: "Bob")] + let found = ["alice": profile("alice", name: "Alice Renamed"), "carol": profile("carol", name: "Carol")] + + let cleared = PubkyChoiceView.mirroredRingProfiles(shown, found: [:], isAdopting: true) + let merged = PubkyChoiceView.mirroredRingProfiles(shown, found: found, isAdopting: true) + + XCTAssertEqual(cleared.mapValues(\.name), ["alice": "Alice", "bob": "Bob"], "Rows keep their profiles while adopting clears the cache") + XCTAssertEqual(merged.mapValues(\.name), ["alice": "Alice Renamed", "bob": "Bob", "carol": "Carol"]) + } + + private func profile(_ publicKey: String, name: String) -> PubkyProfile { + .forDisplay(publicKey: publicKey, name: name, imageUrl: nil) + } } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 51ed0b803..ec0e8f5d7 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1252,23 +1252,81 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testRingAdoptionRefreshesAReusedRowProfileWithoutDecidingProfileSetup() async throws { + func testRingAdoptionRefreshUpdatesAReusedRowProfile() async throws { try await withRestoredProfileDefaults { - for refreshedProfile in [makeProfile(publicKey: ringKeyA, name: "Alice Renamed"), nil] { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - await manager.loadRingIdentityProfiles([bareRingKeyA]) - await stub.setProfile(refreshedProfile, for: ringKeyA) - - _ = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) - await stub.waitForRequests(2) - await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } - - let expectedName = refreshedProfile?.name ?? "Alice" - XCTAssertEqual(manager.profile?.name, expectedName, "A failed refresh keeps the adopted row profile") - XCTAssertEqual(manager.cachedName, expectedName) - XCTAssertFalse(manager.isProfileSetupPending, "Only the adoption decides profile setup") - } + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA]) + await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice Renamed"), for: ringKeyA) + + _ = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + await stub.waitForRequests(2) + await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } + + XCTAssertEqual(manager.profile?.name, "Alice Renamed") + XCTAssertEqual(manager.cachedName, "Alice Renamed") + XCTAssertFalse(manager.isProfileSetupPending) + } + } + + @MainActor + func testRingAdoptionStartsProfileSetupWhenTheReusedRowProfileWasRemoved() async throws { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA]) + await stub.setProfile(nil, for: ringKeyA) + + let adopted = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + XCTAssertEqual(adopted?.name, "Alice", "Adoption reuses the row profile found earlier") + await stub.waitForRequests(2) + await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } + + XCTAssertNil(manager.profile, "A definitive not-found undoes the reused profile") + XCTAssertNil(manager.cachedName) + XCTAssertNil(UserDefaults.standard.string(forKey: "pubky_profile_name")) + XCTAssertNil(UserDefaults.standard.string(forKey: "pubky_profile_owner")) + XCTAssertTrue(manager.isProfileSetupPending) + XCTAssertTrue(UserDefaults.standard.bool(forKey: "pubky_profile_setup_pending")) + XCTAssertEqual(manager.publicKey, ringKeyA) + XCTAssertEqual(manager.authState, .authenticated) + } + } + + @MainActor + func testRingAdoptionKeepsTheReusedRowProfileWhenItsRefreshFails() async throws { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA]) + await stub.makeUnreachable(ringKeyA) + + _ = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + await stub.waitForRequests(2) + await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } + + XCTAssertEqual(manager.profile?.name, "Alice", "An offline refresh keeps the adopted row profile") + XCTAssertEqual(manager.cachedName, "Alice") + XCTAssertFalse(manager.isProfileSetupPending) + } + } + + @MainActor + func testRingAdoptionRefreshDropsANotFoundOnceTheIdentityChanged() async throws { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA]) + await stub.setProfile(nil, for: ringKeyA) + await stub.setHoldsRequests(true) + + _ = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + await stub.waitForRequests(2) + manager.clearAuthenticatedStateForTesting() + await stub.release(request: 1) + await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } + + XCTAssertFalse(manager.isProfileSetupPending, "A refresh for an identity no longer in use decides nothing") } } @@ -2149,8 +2207,9 @@ private let ringKeyB = "pubky\(bareRingKeyB)" private let ringKeyC = "pubky\(bareRingKeyC)" /// Stands in for the remote profile lookup. While holding, each request waits for its own `release(request:)` and then -/// answers from the profiles set at that moment; a key with no profile fails as not found. A request held, or a wait for -/// requests, that outlasts the deadline fails the test and moves on instead of hanging the suite. +/// answers from the profiles set at that moment; a key with no profile fails as not found and an unreachable key fails as +/// offline. A request held, or a wait for requests, that outlasts the deadline fails the test and moves on instead of +/// hanging the suite. private actor RemoteProfileStub { private static let deadline: Duration = .seconds(5) @@ -2158,6 +2217,7 @@ private actor RemoteProfileStub { /// Indexes of the requests whose task was cancelled by the time they answered. private(set) var cancelledRequests: Set = [] private var profiles: [String: PubkyProfile] + private var unreachableKeys: Set = [] private var isHolding: Bool private var heldRequests: [Int: CheckedContinuation] = [:] private var requestWaiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] @@ -2184,10 +2244,15 @@ private actor RemoteProfileStub { if Task.isCancelled { cancelledRequests.insert(index) } + guard !unreachableKeys.contains(key) else { throw URLError(.notConnectedToInternet) } guard let profile = profiles[key] else { throw PubkyServiceError.profileNotFound } return profile } + func makeUnreachable(_ key: String) { + unreachableKeys.insert(key) + } + func waitForRequests(_ count: Int, file: StaticString = #filePath, line: UInt = #line) async { guard requests.count < count else { return } let id = UUID() From 3b56e1ff6a14507d2956beb12bdc86975f2fa4fc Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 07:31:13 +0200 Subject: [PATCH 13/79] fix: show which ring rows are still looking up their profile A Ring row now shows a spinner in its avatar slot until its profile lookup finishes, whatever the outcome, so a row still looking up no longer looks like one whose identity has no profile. The adopting row keeps only its key-icon spinner, and stopped lookups drop their spinner straight away. --- Bitkit/Components/PubkyChoiceRow.swift | 10 +++- Bitkit/Managers/PubkyProfileManager.swift | 13 +++++ .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Views/Profile/PubkyChoiceView.swift | 17 ++++++- BitkitTests/PubkyChoiceViewTests.swift | 20 ++++++++ BitkitTests/PubkyProfileManagerTests.swift | 49 +++++++++++++++++++ journeys/pubky-profile/README.md | 14 ++++-- journeys/pubky-profile/ring-choice-rows.xml | 14 ++++-- 8 files changed, 127 insertions(+), 11 deletions(-) diff --git a/Bitkit/Components/PubkyChoiceRow.swift b/Bitkit/Components/PubkyChoiceRow.swift index 4054a653d..2a06a4b32 100644 --- a/Bitkit/Components/PubkyChoiceRow.swift +++ b/Bitkit/Components/PubkyChoiceRow.swift @@ -9,6 +9,8 @@ struct PubkyChoiceRow: View { var avatarName: String? var avatarImageUrl: String? var isLoading = false + var isLookingUp = false + var lookupAccessibilityId: String? let accessibilityId: String let action: () -> Void @@ -27,7 +29,13 @@ struct PubkyChoiceRow: View { Spacer() - if let avatarName { + if isLookingUp { + ActivityIndicator(size: 20) + .frame(width: 32, height: 32) + .accessibilityElement() + .accessibilityLabel(t("profile__choice_loading_profile")) + .accessibilityIdentifier(lookupAccessibilityId ?? "") + } else if let avatarName { PubkyContactAvatar(name: avatarName, imageUrl: avatarImageUrl, size: 32) } } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 7e8039912..0d7032a6a 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -54,6 +54,9 @@ class PubkyProfileManager: ObservableObject { @Published private(set) var isProfileSetupPending: Bool /// Public profiles found for the Pubky Ring rows on the choice screen, keyed by normalized pubky. Display-only. @Published private(set) var ringIdentityProfiles: [String: PubkyProfile] = [:] + /// Ring rows whose lookup is still running, keyed like `ringIdentityProfiles`. A stopped lookup leaves without waiting + /// for its task to end. + @Published private(set) var ringIdentityLookupsInFlight: Set = [] /// Covers a Ring adoption from before its reference is written, which already makes `hasExistingIdentity` true, until /// it succeeds, fails or is cancelled. @Published private(set) var isAdoptingRingIdentity = false @@ -916,6 +919,8 @@ class PubkyProfileManager: ObservableObject { } } onCancel: { lookups.forEach { $0.cancel() } + // This handler runs off the main actor, so the stopped rows leave the published set on it. + Task { @MainActor [weak self] in self?.publishRingIdentityLookupsInFlight() } } } @@ -931,6 +936,7 @@ class PubkyProfileManager: ObservableObject { for (key, lookup) in ringIdentityLookups where key != keptKey { lookup.task.cancel() } + publishRingIdentityLookupsInFlight() } private func needsRingIdentityLookup(_ key: String) -> Bool { @@ -952,6 +958,7 @@ class PubkyProfileManager: ObservableObject { finishRingIdentityLookup(key, id: id, foundProfile: foundProfile) } ringIdentityLookups[key] = (id, task) + publishRingIdentityLookupsInFlight() return task } @@ -966,6 +973,7 @@ class PubkyProfileManager: ObservableObject { } else if !Task.isCancelled { ringIdentityMisses.insert(key) } + publishRingIdentityLookupsInFlight() } private func clearRingIdentityProfiles() { @@ -973,6 +981,11 @@ class PubkyProfileManager: ObservableObject { ringIdentityLookups.removeAll() ringIdentityMisses.removeAll() ringIdentityProfiles.removeAll() + publishRingIdentityLookupsInFlight() + } + + private func publishRingIdentityLookupsInFlight() { + ringIdentityLookupsInFlight = Set(ringIdentityLookups.filter { !$0.value.task.isCancelled }.keys) } // MARK: - Sign Out diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 3003bf797..558d902f7 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1193,6 +1193,7 @@ "profile__choice_description_ring" = "Use an existing pubky from Pubky Ring."; "profile__choice_create" = "Create profile"; "profile__choice_create_caption" = "New pubky"; +"profile__choice_loading_profile" = "Loading your profile…"; "profile__adopt_error_title" = "Could Not Use Pubky"; "profile__deriving_keys" = "Deriving your keys…"; "profile__create_nav_title" = "Create Profile"; diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index f5368cabc..0730f03b8 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -37,6 +37,11 @@ struct PubkyChoiceView: View { return shown.merging(found) { _, latest in latest } } + /// The row being adopted shows only its key-icon spinner. + static func showsRingLookup(isLookingUp: Bool, hasProfile: Bool, isAdoptingRow: Bool) -> Bool { + isLookingUp && !hasProfile && !isAdoptingRow + } + /// Only the other rows' lookups stop, since they would compete with sign-in while the tapped row's can still land in /// time to be reused. A failed adopt reloads the rows so none is left on a bare key. @MainActor @@ -134,9 +139,11 @@ struct PubkyChoiceView: View { private func ringRow(_ pubky: String) -> some View { let truncatedKey = PubkyPublicKeyFormat.displayTruncated(pubky) - let profile = PubkyPublicKeyFormat.normalized(pubky).flatMap { ringProfiles[$0] } + let key = PubkyPublicKeyFormat.normalized(pubky) + let profile = key.flatMap { ringProfiles[$0] } let name = profile?.name ?? "" let title = name.isEmpty ? truncatedKey : name + let isAdoptingRow = adoptingPubky == pubky return PubkyChoiceRow( systemIcon: "key.fill", @@ -144,7 +151,13 @@ struct PubkyChoiceView: View { title: title, avatarName: title, avatarImageUrl: profile?.imageUrl, - isLoading: adoptingPubky == pubky, + isLoading: isAdoptingRow, + isLookingUp: Self.showsRingLookup( + isLookingUp: key.map { pubkyProfile.ringIdentityLookupsInFlight.contains($0) } ?? false, + hasProfile: profile != nil, + isAdoptingRow: isAdoptingRow + ), + lookupAccessibilityId: "PubkyChoiceRingLookup_\(pubky)", accessibilityId: "PubkyChoiceRing_\(pubky)" ) { startAdopting(pubky) diff --git a/BitkitTests/PubkyChoiceViewTests.swift b/BitkitTests/PubkyChoiceViewTests.swift index dffd1f027..e906d1ee3 100644 --- a/BitkitTests/PubkyChoiceViewTests.swift +++ b/BitkitTests/PubkyChoiceViewTests.swift @@ -35,6 +35,26 @@ final class PubkyChoiceViewTests: XCTestCase { XCTAssertEqual(merged.mapValues(\.name), ["alice": "Alice Renamed", "bob": "Bob", "carol": "Carol"]) } + func testRingLookupShowsOnlyForARowStillLookingUpWithoutAProfileThatIsNotBeingAdopted() { + XCTAssertTrue(PubkyChoiceView.showsRingLookup(isLookingUp: true, hasProfile: false, isAdoptingRow: false)) + XCTAssertFalse( + PubkyChoiceView.showsRingLookup(isLookingUp: false, hasProfile: false, isAdoptingRow: false), + "A finished lookup brings back the avatar" + ) + XCTAssertFalse( + PubkyChoiceView.showsRingLookup(isLookingUp: true, hasProfile: true, isAdoptingRow: false), + "A found profile replaces the spinner" + ) + XCTAssertFalse( + PubkyChoiceView.showsRingLookup(isLookingUp: true, hasProfile: false, isAdoptingRow: true), + "The adopting row shows only its key-icon spinner" + ) + XCTAssertFalse(PubkyChoiceView.showsRingLookup(isLookingUp: true, hasProfile: true, isAdoptingRow: true)) + XCTAssertFalse(PubkyChoiceView.showsRingLookup(isLookingUp: false, hasProfile: true, isAdoptingRow: false)) + XCTAssertFalse(PubkyChoiceView.showsRingLookup(isLookingUp: false, hasProfile: false, isAdoptingRow: true)) + XCTAssertFalse(PubkyChoiceView.showsRingLookup(isLookingUp: false, hasProfile: true, isAdoptingRow: true)) + } + private func profile(_ publicKey: String, name: String) -> PubkyProfile { .forDisplay(publicKey: publicKey, name: name, imageUrl: nil) } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index ec0e8f5d7..98ff4e991 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1146,6 +1146,50 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") } + @MainActor + func testRingIdentityLookupsInFlightListEachRowUntilItsLookupFinishes() async throws { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], holdsRequests: true) + await stub.makeUnreachable(ringKeyC) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB, bareRingKeyC]) } + await stub.waitForRequests(3) + var running: Set = [ringKeyA, ringKeyB, ringKeyC] + XCTAssertEqual(manager.ringIdentityLookupsInFlight, running, "Rows are listed under the normalized pubky") + + // A found profile, a miss and an offline error each end only their own row's lookup. + let requests = await stub.requests + for key in [ringKeyA, ringKeyB, ringKeyC] { + try await stub.release(request: XCTUnwrap(requests.firstIndex(of: key))) + running.remove(key) + await waitUntil("only the finished row leaves the in-flight set") { manager.ringIdentityLookupsInFlight == running } + } + await rows.value + XCTAssertTrue(manager.ringIdentityLookupsInFlight.isEmpty) + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") + } + + @MainActor + func testStoppedRingIdentityLookupLeavesTheInFlightSetAndAReplacedOneKeepsTheNewerListed() async { + let stub = RemoteProfileStub(holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + + let abandoned = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(1) + abandoned.cancel() + await waitUntil("the stopped row leaves before its lookup ends") { manager.ringIdentityLookupsInFlight.isEmpty } + + let current = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(2) + XCTAssertEqual(manager.ringIdentityLookupsInFlight, [ringKeyA]) + await stub.release(request: 0) + await abandoned.value + XCTAssertEqual(manager.ringIdentityLookupsInFlight, [ringKeyA], "A replaced lookup ending leaves the newer one listed") + + await stub.release(request: 1) + await current.value + XCTAssertTrue(manager.ringIdentityLookupsInFlight.isEmpty) + } + @MainActor func testClearingAuthenticatedStateDropsRingIdentityProfilesAndLookups() async { await withRestoredProfileDefaults { @@ -1156,12 +1200,15 @@ final class PubkyProfileManagerTests: XCTestCase { let inFlight = Task { await manager.loadRingIdentityProfiles([bareRingKeyC]) } await stub.waitForRequests(3) + XCTAssertEqual(manager.ringIdentityLookupsInFlight, [ringKeyC]) manager.publicKey = ringKeyA manager.clearAuthenticatedStateForTesting() + XCTAssertTrue(manager.ringIdentityLookupsInFlight.isEmpty, "The reset drops a row lookup before its task ends") await stub.setProfile(makeProfile(publicKey: ringKeyC, name: "Carol"), for: ringKeyC) await stub.release(request: 2) await inFlight.value XCTAssertTrue(manager.ringIdentityProfiles.isEmpty) + XCTAssertTrue(manager.ringIdentityLookupsInFlight.isEmpty) await stub.setHoldsRequests(false) await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) @@ -1344,6 +1391,7 @@ final class PubkyProfileManagerTests: XCTestCase { XCTFail("Expected adopting without a Pubky Ring key to fail") } catch {} XCTAssertEqual(rowReloads, 1, "A failed adopt reloads the rows") + XCTAssertEqual(manager.ringIdentityLookupsInFlight, [ringKeyA], "The other row leaves before its lookup ends; the tapped row stays") await stub.release(request: 0) await stub.release(request: 1) @@ -1352,6 +1400,7 @@ final class PubkyProfileManagerTests: XCTestCase { let cancelledRequests = await stub.cancelledRequests XCTAssertEqual(cancelledRequests, Set(requests.indices.filter { requests[$0] == ringKeyB }), "Only the other row's lookup stops") XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice", "The tapped row's lookup still lands") + XCTAssertTrue(manager.ringIdentityLookupsInFlight.isEmpty) await stub.setHoldsRequests(false) await stub.setProfile(makeProfile(publicKey: ringKeyB, name: "Bob"), for: ringKeyB) diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 334c39941..4c40c53af 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -8,8 +8,11 @@ it behaves differently in both places (see [iOS vs Android](#ios-vs-android)). still loading. Profile shows the name cached from the last load, read-only, then swaps in the full profile in place. - `ring-choice-rows.xml` opens the choice screen with Pubky Ring identities. The rows show straight - away under truncated keys and fill in names as each lookup finishes. Tapping a row adopts that - identity: only the tapped row shows a spinner, and every row is disabled until adoption finishes. + away under truncated keys and fill in names as each lookup finishes. While a row's lookup runs, a + spinner stands in for its avatar, so a row still looking up does not look like a row whose + identity has no profile. A lookup for an identity with no profile can take several seconds to give + up. Tapping a row adopts that identity: only the tapped row shows a spinner, and every row is + disabled until adoption finishes. ## Preconditions @@ -54,7 +57,9 @@ header showed. - **Ring choice rows.** Android's `PubkyChoiceScreen` replaces the rows with one loading line while it loads and while it adopts, and tags every row `PubkyChoiceIdentity`. iOS keeps the rows up, spins only the tapped row, and tags each row `PubkyChoiceRing_`, using the bare z32 key - without the `pubky` prefix. + without the `pubky` prefix. While a row's lookup runs, iOS shows a spinner in place of its avatar, + tagged `PubkyChoiceRingLookup_`. Adopting a row stops the other rows' lookups, so their + spinners go, and the tapped row keeps its avatar. ## Identifiers used @@ -63,6 +68,7 @@ header showed. - Profile: cached header `ProfileCachedHeader` and its name `ProfileCachedName`; full profile name `ProfileViewName`; actions `ProfileEdit`, `ProfileCopy`, `ProfileShare`, `ProfileQRCode` and `ProfileAddTag`. -- Choice screen: Ring rows `PubkyChoiceRing_` and the create option `PubkyChoiceCreate`. +- Choice screen: Ring rows `PubkyChoiceRing_`, a row's lookup spinner + `PubkyChoiceRingLookup_`, and the create option `PubkyChoiceCreate`. - After adopting: the contact import overview `ContactImportOverviewProfile`, or Pay Contacts `PayContactsContinue`. diff --git a/journeys/pubky-profile/ring-choice-rows.xml b/journeys/pubky-profile/ring-choice-rows.xml index 6f0d1a89d..dd5ceb9fe 100644 --- a/journeys/pubky-profile/ring-choice-rows.xml +++ b/journeys/pubky-profile/ring-choice-rows.xml @@ -1,8 +1,10 @@ Verifies that the Pubky Ring choice screen lists every Ring identity straight away under its - truncated key and fills each name in as its profile lookup finishes. It also verifies that - tapping a row shows a spinner on that row only and disables every row until adoption finishes. + truncated key and fills each name in as its profile lookup finishes. While a row's lookup runs, + a spinner takes the place of its avatar, so a row still looking up can be told apart from a row + whose identity has no profile. It also verifies that tapping a row shows a spinner on that row + only and disables every row until adoption finishes. Precondition, outside the Capabilities table in journeys/README.md: Pubky Ring installed on the same simulator or device and signed by the same Apple team, so it shares Bitkit's pubky.shared @@ -15,7 +17,8 @@ iOS-only, with no Android counterpart. Android shows one loading line in place of the rows while it loads and while it adopts, and tags every row "PubkyChoiceIdentity". iOS keeps the - rows up and tags each one "PubkyChoiceRing_<pubky>". See README.md. + rows up and tags each one "PubkyChoiceRing_<pubky>", with "PubkyChoiceRingLookup_<pubky>" + on its lookup spinner. The row avatar and the adoption spinner have no identifier. See README.md. Run `xcrun simctl terminate <UDID> to.bitkit`, then `xcrun simctl launch <UDID> to.bitkit`, so no Ring row profile is remembered from earlier in the session @@ -24,12 +27,15 @@ If the Profile intro (id "ProfileIntro") appears, tap Continue (id "ProfileIntro-button") Verify the choice screen shows one row per Ring identity (id "PubkyChoiceRing_<ring-pubky>") and no create option (id "PubkyChoiceCreate") Verify every row is already tappable and captioned with its truncated key in capitals: the key's first four and last four characters around "...". A row whose name has not arrived also shows that truncated key as its title. If every name is already showing, the lookups finished first: report "already resolved" rather than failing + Verify each row whose lookup is still running shows its key icon and a spinner (id "PubkyChoiceRingLookup_<ring-pubky>") in place of its avatar. If every row already shows its avatar, the lookups finished first: report "already resolved" rather than failing Wait for the row for the named identity (id "PubkyChoiceRing_<named-ring-pubky>") to show its published name as its title Verify that row keeps its truncated key as its caption, and any row whose identity has no published name still shows its truncated key as its title + Wait up to a minute for each row's lookup spinner (id "PubkyChoiceRingLookup_<ring-pubky>") to be gone. The lookup for an identity with no profile can take several seconds to give up + Verify no row shows the lookup spinner (id "PubkyChoiceRingLookup_<ring-pubky>") any more: every row shows its avatar, either its profile picture or the first character of its title. The avatar has no identifier, so check it from a screenshot Tap the row for the named identity (id "PubkyChoiceRing_<named-ring-pubky>") Straight away, capture a snapshot and a screenshot before adoption finishes Verify from the snapshot that every Ring row is disabled while the identity is adopted. A disabled button drops out of the snapshot's Targets list, so the signal is the "PubkyChoiceRing_" rows missing from Targets while the choice screen is still showing. If adoption finished before the capture, report "already adopted" rather than failing - Verify from the screenshot that only the tapped row shows a spinner in place of its key icon, while every other row keeps its key icon and its name or truncated key. The spinner has no identifier, so a snapshot cannot settle this. If adoption finished before the capture, report "already adopted" rather than failing + Verify from the screenshot that only the tapped row shows a spinner, in place of its key icon, while it keeps its avatar and every other row keeps its key icon and its name or truncated key. The key-icon spinner has no identifier, so a snapshot cannot settle this. If adoption finished before the capture, report "already adopted" rather than failing Verify the choice screen gives way to the contact import overview (id "ContactImportOverviewProfile") or Pay Contacts (id "PayContactsContinue") From f5250e8042e5bcd2518c3f7cb4bc94e95bc3e2c9 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 08:35:38 +0200 Subject: [PATCH 14/79] fix: wait for the signing identity's running republish before approving and share ring row lookups between choice screens --- Bitkit/Managers/PubkyProfileManager.swift | 61 ++++++++++++++----- Bitkit/Services/PubkyService.swift | 41 +++++++++++-- BitkitTests/PubkyIdentityRepublishTests.swift | 57 +++++++++++++++++ BitkitTests/PubkyProfileManagerTests.swift | 58 +++++++++++++++++- 4 files changed, 196 insertions(+), 21 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 0d7032a6a..2994f33ca 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -23,6 +23,30 @@ enum PubkySignupError: Error { case inProgress } +/// Counts the choice-screen loads still waiting on one Ring row lookup, starting with the load that started it. A load +/// leaves from its cancellation handler, which runs off the main actor, so the count is locked. +private final class RingIdentityLookupLoaders: @unchecked Sendable { + private let lock = NSLock() + private var count = 1 + + /// Fails once every load has left, since the lookup is then being cancelled. + func join() -> Bool { + lock.withLock { + guard count > 0 else { return false } + count += 1 + return true + } + } + + /// True for the last load to leave, which cancels the lookup. + func leave() -> Bool { + lock.withLock { + count -= 1 + return count == 0 + } + } +} + @MainActor class PubkyProfileManager: ObservableObject { enum SessionInitializationResult: Equatable { @@ -72,7 +96,8 @@ class PubkyProfileManager: ObservableObject { /// Ring rows whose lookup found nothing. The SDK reports a missing record and an offline failure alike, so a miss /// only stops repeat lookups and must never drive sign-up or profile-setup decisions. private var ringIdentityMisses: Set = [] - private var ringIdentityLookups: [String: (id: UUID, task: Task)] = [:] + private typealias RingIdentityLookup = (id: UUID, task: Task, loaders: RingIdentityLookupLoaders) + private var ringIdentityLookups: [String: RingIdentityLookup] = [:] private static func beginSessionMutation() { sessionRevision = UUID() @@ -906,19 +931,20 @@ class PubkyProfileManager: ObservableObject { // MARK: - Pubky Ring Choice Rows - /// Looks up the rows that have no cached result and no live lookup. Cancelling the caller cancels the lookups it - /// started, so a screen the user has left stops queueing for read slots. + /// Looks up the rows that have no cached result, joining the live lookup of a row that has one. Cancelling the caller + /// cancels each of its lookups that no other caller still waits on, so a screen the user has left stops queueing for + /// read slots without stopping rows another choice screen still shows. func loadRingIdentityProfiles(_ pubkys: [String]) async { - let lookups = Set(pubkys.compactMap(PubkyPublicKeyFormat.normalized)) - .filter { needsRingIdentityLookup($0) } - .map { startRingIdentityLookup($0) } + let lookups = Set(pubkys.compactMap(PubkyPublicKeyFormat.normalized)).compactMap { joinRingIdentityLookup($0) } await withTaskCancellationHandler { for lookup in lookups { - await lookup.value + await lookup.task.value } } onCancel: { - lookups.forEach { $0.cancel() } + for lookup in lookups where lookup.loaders.leave() { + lookup.task.cancel() + } // This handler runs off the main actor, so the stopped rows leave the published set on it. Task { @MainActor [weak self] in self?.publishRingIdentityLookupsInFlight() } } @@ -939,13 +965,17 @@ class PubkyProfileManager: ObservableObject { publishRingIdentityLookupsInFlight() } - private func needsRingIdentityLookup(_ key: String) -> Bool { - guard ringIdentityProfiles[key] == nil, !ringIdentityMisses.contains(key) else { return false } - // A cancelled lookup records no miss when it fails, so it cannot stand in for a live one. - return ringIdentityLookups[key]?.task.isCancelled ?? true + private func joinRingIdentityLookup(_ key: String) -> RingIdentityLookup? { + guard ringIdentityProfiles[key] == nil, !ringIdentityMisses.contains(key) else { return nil } + // A cancelled lookup records no miss when it fails, so it cannot stand in for a live one, and one every caller + // has left is being cancelled. + if let lookup = ringIdentityLookups[key], !lookup.task.isCancelled, lookup.loaders.join() { + return lookup + } + return startRingIdentityLookup(key) } - private func startRingIdentityLookup(_ key: String) -> Task { + private func startRingIdentityLookup(_ key: String) -> RingIdentityLookup { let id = UUID() let resolve = remoteProfileResolver let task = Task { @@ -957,9 +987,10 @@ class PubkyProfileManager: ObservableObject { } finishRingIdentityLookup(key, id: id, foundProfile: foundProfile) } - ringIdentityLookups[key] = (id, task) + let lookup = (id: id, task: task, loaders: RingIdentityLookupLoaders()) + ringIdentityLookups[key] = lookup publishRingIdentityLookupsInFlight() - return task + return lookup } /// Runs on the lookup's own task, so `Task.isCancelled` is that lookup's cancellation. diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index ce3849fbc..87124d9a0 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -82,7 +82,7 @@ enum PubkyService { secretKeyHex: String, sdkService: PaykitSdkService = .shared ) async throws { - try await sdkService.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try await sdkService.republishIdentityBeforeApproval(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) try Task.checkCancellation() try await sdkService.approveAuth( authUrl: authUrl, @@ -93,7 +93,7 @@ enum PubkyService { } static func approveRingAuth(authUrl: String, secretKeyHex: String, sdkService: PaykitSdkService = .shared) async throws { - try await sdkService.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try await sdkService.republishIdentityBeforeApproval(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) try Task.checkCancellation() try await ServiceQueue.background(.core) { try await BitkitCore.approvePubkyAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) @@ -107,7 +107,7 @@ enum PubkyService { secretKeyHex: String, sdkService: PaykitSdkService = .shared ) async throws { - try await sdkService.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) + try await sdkService.republishIdentityBeforeApproval(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) try Task.checkCancellation() try await sdkService.approveAuthWithCompanionClaim( authUrl: authUrl, @@ -338,6 +338,9 @@ actor PaykitSdkService { private let bootstrapFactory: BootstrapFactory private var cachedBootstrap: PubkySessionBootstrap? private var isRepublishingIdentity = false + /// The normalized identity the running publication is for, so an approval signing with it can wait for it. + private var republishingIdentity: String? + private var republishWaiters: [AsyncStream.Continuation] = [] private var republishPublicKey: String? private var nextIdentityRepublishAt = Date.distantPast private var lastIdentityRepublishAt = Date.distantPast @@ -408,6 +411,29 @@ actor PaykitSdkService { await republishIdentity(publicKey: publicKey, now: now) continuation.finish() } + defer { publication.cancel() } + await waitForRepublish(stream, finishedBy: continuation, timeout: timeout) + } + + /// For auth approvals. Background triggers skip a publication that is already running, but an approval must still + /// follow its signing identity's publication, such as the one sign-in starts without waiting, so it waits for that + /// one under the same cap instead. + func republishIdentityBeforeApproval(publicKey: String, timeout: Duration = .seconds(5)) async { + guard !Task.isCancelled else { return } + guard let runningIdentity = republishingIdentity, runningIdentity == PubkyPublicKeyFormat.normalized(publicKey) else { + await republishIdentityIfNeeded(publicKey: publicKey, timeout: timeout) + return + } + let (stream, continuation) = AsyncStream.makeStream() + republishWaiters.append(continuation) + await waitForRepublish(stream, finishedBy: continuation, timeout: timeout) + } + + private func waitForRepublish( + _ stream: AsyncStream, + finishedBy continuation: AsyncStream.Continuation, + timeout: Duration + ) async { let deadline = Task { do { try await Task.sleep(for: timeout) @@ -418,7 +444,6 @@ actor PaykitSdkService { continuation.finish() } defer { - publication.cancel() deadline.cancel() continuation.finish() } @@ -433,7 +458,12 @@ actor PaykitSdkService { private func republishIdentity(publicKey: String?, now: Date) async { guard !Task.isCancelled, !isRepublishingIdentity else { return } isRepublishingIdentity = true - defer { isRepublishingIdentity = false } + defer { + isRepublishingIdentity = false + republishingIdentity = nil + republishWaiters.forEach { $0.finish() } + republishWaiters.removeAll() + } do { let identity = try publicKey ?? sessionProvider.loadLocalSecretKey().map { @@ -446,6 +476,7 @@ actor PaykitSdkService { republishPublicKey = identity lastIdentityRepublishAt = now nextIdentityRepublishAt = now.addingTimeInterval(60) + republishingIdentity = identity if try await bootstrap().republishIdentity(publicKey: identity) { nextIdentityRepublishAt = now.addingTimeInterval(30 * 60) Logger.debug("Republished Pubky identity", context: "PaykitSdkService") diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift index 98b6ded1b..4ac327baa 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -156,6 +156,63 @@ final class PubkyIdentityRepublishTests: XCTestCase { await fulfillment(of: [finished], timeout: 1) } + func testApprovalWaitsForTheSigningIdentityPublicationSignInStarted() async throws { + let signingKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "01", count: 32)) + for kind in Approval.allCases { + let started = expectation(description: "Publication started for \(kind)") + let published = expectation(description: "Publication finished for \(kind)") + let proceeded = expectation(description: "\(kind) approval proceeded") + let gate = AsyncStream.makeStream() + let bootstrap = RepublishBootstrap(noPointer: .init()) + let service = PaykitSdkService { _, _ in bootstrap } + bootstrap.operation = { _ in + started.fulfill() + for await _ in gate.stream { + break + } + published.fulfill() + return true + } + // Sign-in starts the republish this way and does not wait for it. + await service.startIdentityRepublish(publicKey: signingKey) + await fulfillment(of: [started], timeout: 1) + + let caller = Task { + // The ring path then fails on its invalid URL, which still shows the approval went ahead. + try? await approve(kind, using: service) + proceeded.fulfill() + } + try await Task.sleep(for: .milliseconds(200)) + gate.continuation.yield() + + await fulfillment(of: [published, proceeded], timeout: 2, enforceOrder: true) + await caller.value + XCTAssertEqual(bootstrap.publicKeys, [signingKey], "\(kind) waits for the running publication instead of starting another") + } + } + + func testApprovalStopsWaitingForARunningPublicationAtTheCap() async { + let started = expectation(description: "Publication started") + let gate = AsyncStream.makeStream() + let bootstrap = RepublishBootstrap(noPointer: .init()) + bootstrap.operation = { _ in + started.fulfill() + for await _ in gate.stream {} + return true + } + let service = PaykitSdkService { _, _ in bootstrap } + await service.startIdentityRepublish(publicKey: publicKey) + await fulfillment(of: [started], timeout: 1) + + let clock = ContinuousClock() + let waitStarted = clock.now + await service.republishIdentityBeforeApproval(publicKey: publicKey, timeout: .milliseconds(100)) + + XCTAssertGreaterThanOrEqual(clock.now - waitStarted, .milliseconds(100)) + XCTAssertEqual(bootstrap.publicKeys, ["pubky\(publicKey)"]) + gate.continuation.finish() + } + func testAuthRepublishesSigningIdentityBeforeApprovalEvenWhenPublicationFails() async throws { for kind in [Approval.ordinary, .companion] { for result in [Result.success(true), .failure(PubkyServiceError.profileNotFound)] { diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 98ff4e991..23dbfda80 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1111,15 +1111,71 @@ final class PubkyProfileManagerTests: XCTestCase { let first = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } await stub.waitForRequests(1) + // The second load waits on the lookup it joins, so the request is released behind it. + let release = Task { await stub.release(request: 0) } await manager.loadRingIdentityProfiles([bareRingKeyA, ringKeyA]) - await stub.release(request: 0) + await release.value await first.value let requests = await stub.requests XCTAssertEqual(requests, [ringKeyA]) XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") } + @MainActor + func testRingIdentityLookupKeepsRunningWhileAnotherLoadStillWantsIt() async throws { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + let first = Task { await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) } + await stub.waitForRequests(2) + // Its own row's request shows the second load has also joined the shared row's lookup. + let second = Task { await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyC]) } + await stub.waitForRequests(3) + + first.cancel() + await waitUntil("only the first load's own row leaves") { manager.ringIdentityLookupsInFlight == [ringKeyA, ringKeyC] } + + let requests = await stub.requests + let sharedRequest = try XCTUnwrap(requests.firstIndex(of: ringKeyA)) + await stub.release(request: sharedRequest) + await waitUntil("the shared row's result is recorded") { manager.ringIdentityProfiles[ringKeyA] != nil } + XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") + XCTAssertEqual(manager.ringIdentityLookupsInFlight, [ringKeyC]) + + second.cancel() + for index in requests.indices where index != sharedRequest { + await stub.release(request: index) + } + await first.value + await second.value + let cancelledRequests = await stub.cancelledRequests + XCTAssertEqual(cancelledRequests, Set(requests.indices.filter { $0 != sharedRequest }), "Only rows no load still wants stop") + XCTAssertTrue(manager.ringIdentityLookupsInFlight.isEmpty) + } + + @MainActor + func testRingIdentityLookupStopsOnceEveryLoadWantingItIsCancelled() async { + let stub = RemoteProfileStub(holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + let first = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(1) + let second = Task { await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) } + await stub.waitForRequests(2) + + first.cancel() + second.cancel() + await waitUntil("the shared row leaves once no load wants it") { manager.ringIdentityLookupsInFlight.isEmpty } + + await stub.release(request: 0) + await stub.release(request: 1) + await first.value + await second.value + let requests = await stub.requests + let cancelledRequests = await stub.cancelledRequests + XCTAssertEqual(requests, [ringKeyA, ringKeyB], "The second load joined the shared row instead of looking it up again") + XCTAssertEqual(cancelledRequests, [0, 1]) + } + @MainActor func testCancelledRingIdentityLookupRecordsNoMissAndIsNotReused() async { let stub = RemoteProfileStub(holdsRequests: true) From ad6b5d5d5cf34f85f8168148fbde0d5db19ff02e Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 08:44:00 +0200 Subject: [PATCH 15/79] fix: finish the contact payments enable after leaving General Settings mid-load --- .../Views/Settings/GeneralSettingsView.swift | 5 ++- BitkitTests/ContactsManagerTests.swift | 44 +++++++++++++++++++ journeys/README.md | 1 + journeys/pubky-profile/README.md | 15 +++---- journeys/pubky-profile/ring-choice-rows.xml | 7 ++- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index e797e8db7..97f3e27d7 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -150,7 +150,8 @@ struct GeneralSettingsView: View { !hasConfirmedContactPaymentsPreference else { return } - await updateContactPayments(true) + // Leaving the screen cancels `.task`; its own task lets the first contacts load and the enable still finish. + Task { await updateContactPayments(true) } } } @@ -176,6 +177,8 @@ struct GeneralSettingsView: View { contactPublicKeys: contactsManager.contacts.map(\.publicKey), canUsePrivatePayments: canUsePrivatePayments ) + } catch is CancellationError { + return } catch { Logger.error("Failed to update contact payments: \(error)", context: "GeneralSettingsView") app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 696acd52e..5aa829422 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -55,6 +55,50 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(manager.isLoading) } + /// General Settings used to await this in its `.task`, so leaving mid-load surfaced the cancellation as an error toast. + func testCancelledLoadContactsIfNeededLeavesTheLoadToAnUncancelledCaller() async throws { + let record = contactRecord(key: "pubky" + String(repeating: "y", count: 52), name: "Contact") + let source = SuspendedContactRecords(records: [record]) + let manager = ContactsManager(contactRecords: { await source.load() }) + let screenLoad = Task { try await manager.loadContactsIfNeeded(for: "owner") } + while await !(source.isPaused) { + await Task.yield() + } + let enable = Task { try await manager.loadContactsIfNeeded(for: "owner") } + await Task.yield() + screenLoad.cancel() + await source.resume(with: [record]) + + let screenResult = await screenLoad.result + XCTAssertThrowsError(try screenResult.get()) { XCTAssertTrue($0 is CancellationError, "Expected cancellation, got \($0)") } + try await enable.value + XCTAssertTrue(manager.hasLoaded) + XCTAssertEqual(manager.contacts.map(\.publicKey), [record.publicKey]) + XCTAssertNil(manager.loadErrorMessage) + XCTAssertFalse(manager.isLoading) + } + + func testLoadContactsIfNeededFinishesAfterTheContactsScreenLoadItWaitedOnIsCancelled() async throws { + let record = contactRecord(key: "pubky" + String(repeating: "y", count: 52), name: "Contact") + let source = SuspendedContactRecords(records: [record]) + let manager = ContactsManager(contactRecords: { await source.load() }) + let contactsScreenLoad = Task { try await manager.loadContacts(for: "owner") } + while await !(source.isPaused) { + await Task.yield() + } + let enable = Task { try await manager.loadContactsIfNeeded(for: "owner") } + await Task.yield() + contactsScreenLoad.cancel() + await source.resume(with: [record]) + + try await contactsScreenLoad.value + try await enable.value + XCTAssertTrue(manager.hasLoaded) + XCTAssertEqual(manager.contacts.map(\.publicKey), [record.publicKey]) + XCTAssertNil(manager.loadErrorMessage) + XCTAssertFalse(manager.isLoading) + } + private func contactRecord(key: String, name: String) -> ContactRecord { ContactRecord( publicKey: key, receiverPaths: [PaykitReceiverPath.wallet], label: name, diff --git a/journeys/README.md b/journeys/README.md index 826d2dbb8..5e9c9a6f1 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -125,6 +125,7 @@ Known naming differences: | External amount available | — | `ExternalAmountAvailable` | | Payment Request details screen | `PaymentRequestDetailsScreen` | `PaymentRequestDetailScreen` | | Pubky Ring choice row | `PubkyChoiceIdentity` (the same tag on every row) | `PubkyChoiceRing_` (bare z32 key) | +| Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` | `PubkyChoiceRingLookup_` (bare z32 key) | | Cached profile header while the profile loads | — | `ProfileCachedHeader`, with the name `ProfileCachedName` | `SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 4c40c53af..5edeca8cc 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -1,8 +1,7 @@ # Pubky profile loading This suite covers what Profile and the Pubky Ring choice screen show while their profiles are still -loading from the network. Both journeys are iOS-only: Android has no journey for either screen, and -it behaves differently in both places (see [iOS vs Android](#ios-vs-android)). +loading from the network. Where Android differs, see [iOS vs Android](#ios-vs-android). - `cached-profile-header.xml` opens Profile straight after a relaunch, while the signed-in profile is still loading. Profile shows the name cached from the last load, read-only, then swaps in the full @@ -54,12 +53,12 @@ header showed. - **Profile while loading.** Android shows only a spinner until the profile loads. iOS shows the cached name and avatar read-only under `ProfileCachedHeader`, with no edit, copy, share, QR code or tag controls, and then the full profile under `ProfileViewName`. -- **Ring choice rows.** Android's `PubkyChoiceScreen` replaces the rows with one loading line while - it loads and while it adopts, and tags every row `PubkyChoiceIdentity`. iOS keeps the rows up, - spins only the tapped row, and tags each row `PubkyChoiceRing_`, using the bare z32 key - without the `pubky` prefix. While a row's lookup runs, iOS shows a spinner in place of its avatar, - tagged `PubkyChoiceRingLookup_`. Adopting a row stops the other rows' lookups, so their - spinners go, and the tapped row keeps its avatar. +- **Ring choice rows.** Android names the rows and their lookup spinners differently; see + [Identifiers](../README.md#identifiers). iOS keeps the rows up, spins only the tapped row, and + tags each row `PubkyChoiceRing_`, using the bare z32 key without the `pubky` prefix. + While a row's lookup runs, iOS shows a spinner in place of its avatar, tagged + `PubkyChoiceRingLookup_`. Adopting a row stops the other rows' lookups, so their spinners + go, and the tapped row keeps its avatar. ## Identifiers used diff --git a/journeys/pubky-profile/ring-choice-rows.xml b/journeys/pubky-profile/ring-choice-rows.xml index dd5ceb9fe..f97592b33 100644 --- a/journeys/pubky-profile/ring-choice-rows.xml +++ b/journeys/pubky-profile/ring-choice-rows.xml @@ -15,10 +15,9 @@ <ring-pubky> is a Ring identity's bare z32 key without the "pubky" prefix, and <named-ring-pubky> is the one with a published name. Tapping that row signs in with it. - iOS-only, with no Android counterpart. Android shows one loading line in place of the rows - while it loads and while it adopts, and tags every row "PubkyChoiceIdentity". iOS keeps the - rows up and tags each one "PubkyChoiceRing_<pubky>", with "PubkyChoiceRingLookup_<pubky>" - on its lookup spinner. The row avatar and the adoption spinner have no identifier. See README.md. + iOS tags each row "PubkyChoiceRing_<pubky>", with "PubkyChoiceRingLookup_<pubky>" on its + lookup spinner; Android names both differently, as the Identifiers table in journeys/README.md + records. The row avatar and the adoption spinner have no identifier. See README.md. Run `xcrun simctl terminate <UDID> to.bitkit`, then `xcrun simctl launch <UDID> to.bitkit`, so no Ring row profile is remembered from earlier in the session From 860e5b618c9378bd611762dee4ab34ba790c7c3a Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 09:18:54 +0200 Subject: [PATCH 16/79] docs: drop Android claims from the cached profile header journey --- journeys/pubky-profile/README.md | 6 +++--- journeys/pubky-profile/cached-profile-header.xml | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 5edeca8cc..c0ec88b99 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -50,9 +50,9 @@ header showed. ## iOS vs Android -- **Profile while loading.** Android shows only a spinner until the profile loads. iOS shows the - cached name and avatar read-only under `ProfileCachedHeader`, with no edit, copy, share, QR code - or tag controls, and then the full profile under `ProfileViewName`. +- **Profile while loading.** iOS shows the cached name and avatar read-only under + `ProfileCachedHeader`, with no edit, copy, share, QR code or tag controls, and then the full + profile under `ProfileViewName`. - **Ring choice rows.** Android names the rows and their lookup spinners differently; see [Identifiers](../README.md#identifiers). iOS keeps the rows up, spins only the tapped row, and tags each row `PubkyChoiceRing_`, using the bare z32 key without the `pubky` prefix. diff --git a/journeys/pubky-profile/cached-profile-header.xml b/journeys/pubky-profile/cached-profile-header.xml index 6e4c7f022..6eb7c3e05 100644 --- a/journeys/pubky-profile/cached-profile-header.xml +++ b/journeys/pubky-profile/cached-profile-header.xml @@ -8,9 +8,8 @@ identity whose profile has a name. Profile must have loaded fully once on this build, so the cache records which key the name belongs to. Use the test simulator UDID for <UDID>. - iOS-only, with no Android counterpart. Android shows a spinner until the profile loads. A warm - network can finish the load before the first snapshot, so an "already loaded" result repeats - the relaunch once instead of failing. See README.md. + A warm network can finish the load before the first snapshot, so an "already loaded" result + repeats the relaunch once instead of failing. See README.md. Tap the header profile button (id "ProfileButton") From 7b98b0130803c863d75ef8d75c2c2f68ac49b1c3 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 15:52:44 +0200 Subject: [PATCH 17/79] fix: keep public read slots free for what the user is looking at Public reads now have a bulk lane: contact list and import profile lookups take one of four bulk slots before one of the six read slots, so they can never hold more than four. Avatars, the own profile, Ring rows and single-contact lookups take only a read slot, so at least two stay free for them while a large contact list loads. --- Bitkit/Managers/ContactsManager.swift | 15 ++- Bitkit/Services/PubkyService.swift | 63 ++++++++++-- BitkitTests/PaykitPublicReadLaneTests.swift | 85 +++++++++++++++++ BitkitTests/PaykitSdkReadLimiterTests.swift | 100 ++++++++++++++++++++ 4 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 BitkitTests/PaykitPublicReadLaneTests.swift diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 4d0f64b89..de6ac6ede 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -183,7 +183,7 @@ class ContactsManager: ObservableObject { try await loadContacts( for: publicKey, fetchContactRecords: contactRecords, - fetchRemoteProfile: Self.fetchRemoteContactProfile + fetchRemoteProfile: Self.fetchRemoteContactProfileInBulk ) } @@ -579,9 +579,13 @@ class ContactsManager: ObservableObject { let discoveryResult: (contacts: [PubkyContact], failures: Int) = await withTaskGroup(of: Result.self) { group in for key in contactKeys { let pk = ensurePubkyPrefix(key) - group.addTask { [self] in + group.addTask { do { - let profile = try await resolveContactProfile(publicKey: pk, includePlaceholder: true) + let profile = try await Self.resolveContactProfile( + publicKey: pk, + includePlaceholder: true, + fetchRemoteProfile: Self.fetchRemoteContactProfileInBulk + ) return .success(PubkyContact(publicKey: pk, profile: profile)) } catch { return .failure(error) @@ -660,6 +664,11 @@ class ContactsManager: ObservableObject { try await PubkyService.resolveContactProfile(publicKey: $0, allowPubkyProfileFallback: true).map(PubkyProfile.init(resolution:)) } + nonisolated static let fetchRemoteContactProfileInBulk: @Sendable (String) async throws -> PubkyProfile? = { + try await PubkyService.resolveContactProfile(publicKey: $0, allowPubkyProfileFallback: true, priority: .bulk) + .map(PubkyProfile.init(resolution:)) + } + /// A missing profile is never retried. `retryTransient` retries any other failure once and is for user-initiated /// lookups only: the SDK reports a key with no pkarr record and a network failure as the same transport error, so a /// retry in bulk loads doubles the wait for every contact without a profile. diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 3e5aeab60..a1ccf24fd 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -46,6 +46,13 @@ struct PubkyRegisteredIdentity { let walletGeneration: Int } +/// Which public read slots a read may use. Background work that reads for many contacts at once is `bulk`, so it can +/// never take every read slot from reads for what the user is looking at. +enum PaykitPublicReadPriority { + case interactive + case bulk +} + /// Service layer for Pubky sessions, profiles, contacts, and Paykit SDK workflows. enum PubkyService { static func initialize() async throws { @@ -302,8 +309,16 @@ enum PubkyService { try await PaykitSdkService.shared.removeContact(publicKey: publicKey) } - static func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ContactProfileResolution? { - try await PaykitSdkService.shared.resolveContactProfile(publicKey: publicKey, allowPubkyProfileFallback: allowPubkyProfileFallback) + static func resolveContactProfile( + publicKey: String, + allowPubkyProfileFallback: Bool, + priority: PaykitPublicReadPriority = .interactive + ) async throws -> Paykit.ContactProfileResolution? { + try await PaykitSdkService.shared.resolveContactProfile( + publicKey: publicKey, + allowPubkyProfileFallback: allowPubkyProfileFallback, + priority: priority + ) } static func discoverRelevantReceiverPaths(publicKey: String) async throws -> [String] { @@ -337,7 +352,7 @@ actor PaykitSdkService { private let sessionProvider = PaykitSdkSessionProvider() private let paymentAdapter = PaykitSdkPaymentAdapter() private let operationLock = PaykitSdkOperationLock() - private let publicReadLimiter = PaykitSdkReadLimiter(maxConcurrent: 6) + private let publicReadSlots = PaykitPublicReadSlots() private let pubkyClientConfig = PaykitSdkService.makePubkyClientConfig(localTestnetHost: Env.pubkyLocalTestnetHost) private let sdkFactory: (() throws -> PaykitSdk)? private let bootstrapFactory: BootstrapFactory @@ -748,8 +763,12 @@ actor PaykitSdkService { } } - func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ContactProfileResolution? { - try await withPublicRead { + func resolveContactProfile( + publicKey: String, + allowPubkyProfileFallback: Bool, + priority: PaykitPublicReadPriority = .interactive + ) async throws -> Paykit.ContactProfileResolution? { + try await withPublicRead(priority: priority) { try await $0.resolveContactProfile( publicKey: publicKey, receiverPath: PaykitReceiverPath.wallet, @@ -1129,18 +1148,21 @@ actor PaykitSdkService { return created } - /// Public-only read lane: skips `operationLock` and runs up to the limiter's cap at once. It is sound only for reads + /// Public-only read lane: skips `operationLock` and runs up to the read slots' cap at once. It is sound only for reads /// that fetch unauthenticated public Pubky data; in paykit rc56 those use the SDK's public client and never load /// session access, the local secret or the state blob. Session, secret or state-blob operations must never use it. /// Without an SDK it takes `operationLock` only to build one, cancellably, and releases it before the read, so a /// read never builds an SDK outside the lock and never holds the lock across the network call. - private func withPublicRead(_ read: (PaykitSdk) async throws -> T) async throws -> T { + private func withPublicRead( + priority: PaykitPublicReadPriority = .interactive, + _ read: (PaykitSdk) async throws -> T + ) async throws -> T { let instance: PaykitSdk = if let sdk { sdk } else { try await operationLock.withCancellableLock { try handle() } } - return try await publicReadLimiter.withSlot { try await read(instance) } + return try await publicReadSlots.withSlot(priority) { try await read(instance) } } private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { @@ -1572,6 +1594,31 @@ final class PaykitSdkReadLimiter: @unchecked Sendable { } } +/// Slots for the public read lane. Every read holds one of `readCap` read slots while it runs. A bulk read first takes +/// one of `bulkCap` bulk slots, so bulk work holds at most `bulkCap` read slots and the rest stay free for interactive +/// reads. A bulk read takes its bulk slot before its read slot and never waits for a bulk slot while holding a read +/// slot, so the two limiters cannot deadlock. Each limiter keeps its own FIFO order and cancellation behaviour. +final class PaykitPublicReadSlots: Sendable { + private let readSlots: PaykitSdkReadLimiter + private let bulkSlots: PaykitSdkReadLimiter + + init(readCap: Int = 6, bulkCap: Int = 4) { + readSlots = PaykitSdkReadLimiter(maxConcurrent: readCap) + bulkSlots = PaykitSdkReadLimiter(maxConcurrent: bulkCap) + } + + func withSlot(_ priority: PaykitPublicReadPriority, _ operation: () async throws -> T) async throws -> T { + switch priority { + case .interactive: + return try await readSlots.withSlot(operation) + case .bulk: + return try await bulkSlots.withSlot { + try await readSlots.withSlot(operation) + } + } + } +} + extension PublicPaykitService.Endpoint { var paykitPublicReceivingDetail: PublicReceivingDetail { PublicReceivingDetail( diff --git a/BitkitTests/PaykitPublicReadLaneTests.swift b/BitkitTests/PaykitPublicReadLaneTests.swift new file mode 100644 index 000000000..bf6fe6625 --- /dev/null +++ b/BitkitTests/PaykitPublicReadLaneTests.swift @@ -0,0 +1,85 @@ +@testable import Bitkit +import Paykit +import XCTest + +final class PaykitPublicReadLaneTests: XCTestCase { + func testBulkProfileLookupsLeaveReadSlotsForInteractiveFetches() async throws { + let sdk = PublicReadLaneSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let lookups = (0 ..< 6).map { index in + Task { + try await service.resolveContactProfile(publicKey: "contact\(index)", allowPubkyProfileFallback: true, priority: .bulk) + } + } + try await sdk.log.waitForEntries(count: 4) + try await Task.sleep(for: .milliseconds(50)) + let lookupsInFlight = await sdk.log.entries.count + XCTAssertEqual(lookupsInFlight, 4, "Bulk lookups may hold only four of the six read slots") + + let fetched = expectation(description: "Interactive fetch finished") + let fetch = Task { + _ = try await service.fetchFile(uri: "pubky://avatar", maxBytes: 10) + fetched.fulfill() + } + await fulfillment(of: [fetched], timeout: 2) + + await sdk.gate.open() + try await fetch.value + for lookup in lookups { + _ = try await lookup.value + } + } +} + +/// Records the reads that have started, in order. +private actor PublicReadLog { + private(set) var entries: [String] = [] + + func record(_ entry: String) { + entries.append(entry) + } + + func waitForEntries(count: Int) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while entries.count < count, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(5)) + } + } +} + +/// Holds every read that waits on it until it opens. Unlike an `AsyncStream`, any number of reads can wait at once. +private actor PublicReadGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + isOpen = true + waiters.forEach { $0.resume() } + waiters.removeAll() + } +} + +private final class PublicReadLaneSdk: PaykitSdk, @unchecked Sendable { + let log = PublicReadLog() + let gate = PublicReadGate() + + override func resolveContactProfile( + publicKey: String, + receiverPath _: String, + allowPubkyProfileFallback _: Bool + ) async throws -> ContactProfileResolution? { + await log.record("profile:\(publicKey)") + await gate.wait() + return nil + } + + override func fetchPubkyFileBounded(uri: String, maxBytes _: UInt64) async throws -> Data? { + await log.record("file:\(uri)") + return Data() + } +} diff --git a/BitkitTests/PaykitSdkReadLimiterTests.swift b/BitkitTests/PaykitSdkReadLimiterTests.swift index e9ab545ab..d2faa4993 100644 --- a/BitkitTests/PaykitSdkReadLimiterTests.swift +++ b/BitkitTests/PaykitSdkReadLimiterTests.swift @@ -39,6 +39,21 @@ final class PaykitSdkReadLimiterTests: XCTestCase { } } + private func startGatedRead( + _ name: String, + on slots: PaykitPublicReadSlots, + priority: PaykitPublicReadPriority, + recorder: Recorder, + gate: Gate + ) -> Task { + Task { + try await slots.withSlot(priority) { + await recorder.record(name) + await gate.wait() + } + } + } + private func waitForEvents(_ recorder: Recorder, count: Int) async throws { let deadline = ContinuousClock.now.advanced(by: .seconds(2)) while await recorder.events.count < count, ContinuousClock.now < deadline { @@ -156,6 +171,91 @@ final class PaykitSdkReadLimiterTests: XCTestCase { XCTAssertEqual(events, ["payment"]) } + func testBulkReadsLeaveReadSlotsFreeForInteractiveReads() async throws { + let slots = PaykitPublicReadSlots() + let recorder = Recorder() + var gates: [String: Gate] = [:] + var reads: [Task] = [] + func start(_ name: String, _ priority: PaykitPublicReadPriority) { + let gate = Gate() + gates[name] = gate + reads.append(startGatedRead(name, on: slots, priority: priority, recorder: recorder, gate: gate)) + } + + for index in 0 ..< 4 { + start("bulk\(index)", .bulk) + try await waitForEvents(recorder, count: index + 1) + } + start("bulk4", .bulk) + try await Task.sleep(for: .milliseconds(50)) + let bulkEvents = await recorder.events + XCTAssertEqual(bulkEvents, ["bulk0", "bulk1", "bulk2", "bulk3"], "Only four bulk reads may run at once") + + start("interactive0", .interactive) + start("interactive1", .interactive) + try await waitForEvents(recorder, count: 6) + let eventsWithInteractive = await recorder.events + XCTAssertEqual(Set(eventsWithInteractive.suffix(2)), ["interactive0", "interactive1"]) + + start("interactive2", .interactive) + try await Task.sleep(for: .milliseconds(50)) + let eventsAtReadCap = await recorder.events + XCTAssertEqual(eventsAtReadCap.count, 6, "No more than six reads may run at once") + + gates["bulk0"]?.open() + try await waitForEvents(recorder, count: 7) + try await Task.sleep(for: .milliseconds(50)) + let eventsAfterBulkRelease = await recorder.events + XCTAssertEqual(eventsAfterBulkRelease.count, 7) + XCTAssertEqual(eventsAfterBulkRelease.last, "interactive2", "A freed read slot goes to the queued interactive read") + + gates.values.forEach { $0.open() } + for read in reads { + try await read.value + } + let finalEvents = await recorder.events + XCTAssertEqual(finalEvents.last, "bulk4") + } + + func testCancelledBulkReadWaitingForAReadSlotGivesUpItsBulkSlot() async throws { + let slots = PaykitPublicReadSlots(readCap: 1, bulkCap: 1) + let recorder = Recorder() + let holderGate = Gate() + let holder = startGatedRead("holder", on: slots, priority: .interactive, recorder: recorder, gate: holderGate) + try await waitForEvents(recorder, count: 1) + + let abandonedThrew = expectation(description: "Cancelled bulk read threw while the read slot was still held") + let abandoned = Task { + do { + try await slots.withSlot(.bulk) { + await recorder.record("abandoned") + } + } catch is CancellationError { + abandonedThrew.fulfill() + } + } + try await Task.sleep(for: .milliseconds(50)) + let next = Task { + try await slots.withSlot(.bulk) { + await recorder.record("next") + } + } + try await Task.sleep(for: .milliseconds(50)) + + abandoned.cancel() + await fulfillment(of: [abandonedThrew], timeout: 2) + + holderGate.open() + try await abandoned.value + try await holder.value + try await next.value + try await slots.withSlot(.bulk) { + await recorder.record("after") + } + let events = await recorder.events + XCTAssertEqual(events, ["holder", "next", "after"]) + } + func testFailingReadReleasesItsSlotToTheNextWaiter() async throws { let limiter = PaykitSdkReadLimiter(maxConcurrent: 1) let recorder = Recorder() From c7e93b779545fdd6b71612e505df07521acd97ee Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 15:57:24 +0200 Subject: [PATCH 18/79] fix: discover contact receivers without holding the Paykit SDK lock Receiver path and marker reads are public reads, so contact receiver discovery, private receiver path selection and payment request receiver checks now run on the public read lane instead of holding the global SDK lock for every network round trip. Private sync, import and payment request checks use the bulk lane; adding or opening a single contact stays interactive. Saved records are still read and written under the lock, and selection still protects every saved path when no SDK can be built. --- Bitkit/Managers/ContactsManager.swift | 9 +- .../PrivatePaykitService+Contacts.swift | 2 +- Bitkit/Services/PubkyService.swift | 70 +++++----- BitkitTests/PaykitPublicReadLaneTests.swift | 121 ++++++++++++++++++ 4 files changed, 167 insertions(+), 35 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index de6ac6ede..1a8ad5e93 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -388,7 +388,7 @@ class ContactsManager: ObservableObject { group.addTask { [self] in do { let profile = try await resolveContactProfile(publicKey: key, includePlaceholder: true, retryTransient: true) - let receiverPaths = try await Self.relevantReceiverPaths(for: key) + let receiverPaths = try await Self.relevantReceiverPaths(for: key, priority: .bulk) _ = try await PubkyService.saveContact( publicKey: key, label: profile.name, @@ -727,9 +727,12 @@ class ContactsManager: ObservableObject { return true } - private nonisolated static func relevantReceiverPaths(for publicKey: String) async throws -> [String] { + private nonisolated static func relevantReceiverPaths( + for publicKey: String, + priority: PaykitPublicReadPriority = .interactive + ) async throws -> [String] { do { - return try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey) + return try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey, priority: priority) } catch is CancellationError { throw CancellationError() } catch { diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 0f459218e..314117e12 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -808,7 +808,7 @@ extension PrivatePaykitService { let savedPaths = supportedReceiverPaths(record?.receiverPaths ?? []) do { - let discoveredPaths = try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey) + let discoveredPaths = try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey, priority: .bulk) let mergedPaths = supportedReceiverPaths(savedPaths + discoveredPaths) guard mergedPaths != savedPaths else { return savedPaths } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index a1ccf24fd..b226a3319 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -321,8 +321,8 @@ enum PubkyService { ) } - static func discoverRelevantReceiverPaths(publicKey: String) async throws -> [String] { - try await PaykitSdkService.shared.discoverRelevantReceiverPaths(publicKey: publicKey) + static func discoverRelevantReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority = .interactive) async throws -> [String] { + try await PaykitSdkService.shared.discoverRelevantReceiverPaths(publicKey: publicKey, priority: priority) } // MARK: - Sign Out @@ -777,9 +777,10 @@ actor PaykitSdkService { } } - func discoverRelevantReceiverPaths(publicKey: String) async throws -> [String] { - try await operationLock.withLock { - let sdk = try handle() + /// Reads only the contact's public receiver paths and markers, so it runs on the public read lane and never holds + /// `operationLock` across the network. + func discoverRelevantReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority = .interactive) async throws -> [String] { + try await withPublicRead(priority: priority) { sdk in let paths = try await sdk.paykitReceiverPaths(publicKey: publicKey) var discovered = Set() @@ -800,39 +801,46 @@ actor PaykitSdkService { } } - /// Takes the SDK lock per read and drops out of its queue once cancelled, so an abandoned eligibility check - /// holds up a payment for at most the one read already in flight. + /// Public reads on the bulk lane, so it never holds `operationLock`. An abandoned eligibility check leaves the read + /// queue at once and stops before its next read. func paymentRequestReceiverPaths(publicKey: String) async throws -> [String] { try Task.checkCancellation() - let paths = try await operationLock.withCancellableLock { - try await handle().paykitReceiverPaths(publicKey: publicKey) - } - var capablePaths = Set() + return try await withPublicRead(priority: .bulk) { sdk in + let paths = try await sdk.paykitReceiverPaths(publicKey: publicKey) + var capablePaths = Set() - for path in paths where PaykitReceiverPath.supported.contains(path) { - try Task.checkCancellation() - let marker = try await operationLock.withCancellableLock { - try await handle().paykitReceiverMarker(publicKey: publicKey, receiverPath: path) - } - if marker?.capabilities.paymentRequests == true { - capablePaths.insert(path) + for path in paths where PaykitReceiverPath.supported.contains(path) { + try Task.checkCancellation() + let marker = try await sdk.paykitReceiverMarker(publicKey: publicKey, receiverPath: path) + if marker?.capabilities.paymentRequests == true { + capablePaths.insert(path) + } } - } - return PaykitReceiverPath.supported.filter { capablePaths.contains($0) } + return PaykitReceiverPath.supported.filter { capablePaths.contains($0) } + } } + /// Reads only the saved paths' public markers, so the reads run on the bulk lane rather than under `operationLock`. + /// The caller reads the saved paths from the contact record under the lock. When no SDK can be built it reports + /// `sessionNotActive` and protects every saved path from cleanup. func privateReceiverPathSelection(publicKey: String, savedReceiverPaths: [String]) async throws -> PrivateReceiverPathSelection { - try await operationLock.withLock { - let paths = Self.mergedReceiverPaths(savedReceiverPaths) - guard let sdk = try? handle() else { - return PrivateReceiverPathSelection( - linkableReceiverPaths: [], - publishableReceiverPaths: [], - cleanupProtectedReceiverPaths: paths, - error: PubkyServiceError.sessionNotActive - ) - } + let paths = Self.mergedReceiverPaths(savedReceiverPaths) + let instance: PaykitSdk? = if let sdk { + sdk + } else { + try await operationLock.withCancellableLock { try? handle() } + } + guard let instance else { + return PrivateReceiverPathSelection( + linkableReceiverPaths: [], + publishableReceiverPaths: [], + cleanupProtectedReceiverPaths: paths, + error: PubkyServiceError.sessionNotActive + ) + } + + return try await publicReadSlots.withSlot(.bulk) { var linkable: [String] = [] var publishable: [String] = [] var cleanupProtected: [String] = [] @@ -840,7 +848,7 @@ actor PaykitSdkService { for path in paths { do { - let marker = try await sdk.paykitReceiverMarker(publicKey: publicKey, receiverPath: path) + let marker = try await instance.paykitReceiverMarker(publicKey: publicKey, receiverPath: path) if Self.requiresPrivateLink(marker: marker) { linkable.append(path) } diff --git a/BitkitTests/PaykitPublicReadLaneTests.swift b/BitkitTests/PaykitPublicReadLaneTests.swift index bf6fe6625..ecae2bd0e 100644 --- a/BitkitTests/PaykitPublicReadLaneTests.swift +++ b/BitkitTests/PaykitPublicReadLaneTests.swift @@ -29,6 +29,105 @@ final class PaykitPublicReadLaneTests: XCTestCase { _ = try await lookup.value } } + + func testReceiverDiscoveryDoesNotHoldTheSdkLock() async throws { + let sdk = PublicReadLaneSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let discoveries = (0 ..< 5).map { index in + Task { try await service.discoverRelevantReceiverPaths(publicKey: "contact\(index)", priority: .bulk) } + } + + try await assertBulkReadsLeaveTheLockAndReadSlotsFree(sdk: sdk, service: service) + + for discovery in discoveries { + let paths = try await discovery.value + XCTAssertEqual(paths, [PaykitReceiverPath.wallet, PaykitReceiverPath.server]) + } + } + + func testPaymentRequestReceiverPathsDoesNotHoldTheSdkLock() async throws { + let sdk = PublicReadLaneSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let checks = (0 ..< 5).map { index in + Task { try await service.paymentRequestReceiverPaths(publicKey: "contact\(index)") } + } + + try await assertBulkReadsLeaveTheLockAndReadSlotsFree(sdk: sdk, service: service) + + for check in checks { + let paths = try await check.value + XCTAssertEqual(paths, [PaykitReceiverPath.server]) + } + } + + func testPrivateReceiverPathSelectionDoesNotHoldTheSdkLock() async throws { + let sdk = PublicReadLaneSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let selections = (0 ..< 5).map { index in + Task { + try await service.privateReceiverPathSelection( + publicKey: "contact\(index)", + savedReceiverPaths: [PaykitReceiverPath.server] + ) + } + } + + try await assertBulkReadsLeaveTheLockAndReadSlotsFree(sdk: sdk, service: service) + + for selection in selections { + let result = try await selection.value + XCTAssertEqual(result.linkableReceiverPaths, [PaykitReceiverPath.server]) + XCTAssertEqual(result.publishableReceiverPaths, [PaykitReceiverPath.server]) + XCTAssertEqual(result.cleanupProtectedReceiverPaths, []) + XCTAssertNil(result.error) + } + } + + func testPrivateReceiverPathSelectionWithoutAnSdkProtectsEverySavedPath() async throws { + let service = PaykitSdkService(sdkFactory: { throw PubkyServiceError.sessionNotActive }) + + let selection = try await service.privateReceiverPathSelection( + publicKey: "contact", + savedReceiverPaths: [PaykitReceiverPath.server] + ) + + XCTAssertEqual(selection.linkableReceiverPaths, []) + XCTAssertEqual(selection.publishableReceiverPaths, []) + XCTAssertEqual(selection.cleanupProtectedReceiverPaths, [PaykitReceiverPath.wallet, PaykitReceiverPath.server]) + guard case .sessionNotActive? = selection.error as? PubkyServiceError else { + return XCTFail("Expected sessionNotActive, got \(String(describing: selection.error))") + } + } + + /// Expects five bulk reads to have been started, each blocked on its first network read. Four may run, a contact + /// record read under the SDK lock and an interactive fetch must both finish meanwhile, then the gate opens. + private func assertBulkReadsLeaveTheLockAndReadSlotsFree( + sdk: PublicReadLaneSdk, + service: PaykitSdkService, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + try await sdk.log.waitForEntries(count: 4) + try await Task.sleep(for: .milliseconds(50)) + let readsInFlight = await sdk.log.entries.count + XCTAssertEqual(readsInFlight, 4, "Only four bulk reads may run at once", file: file, line: line) + + let lockedReadFinished = expectation(description: "Contact record read under the SDK lock finished") + let lockedRead = Task { + _ = try await service.contactRecords() + lockedReadFinished.fulfill() + } + let fetched = expectation(description: "Interactive fetch finished") + let fetch = Task { + _ = try await service.fetchFile(uri: "pubky://avatar", maxBytes: 10) + fetched.fulfill() + } + await fulfillment(of: [lockedReadFinished, fetched], timeout: 2) + + await sdk.gate.open() + try await lockedRead.value + try await fetch.value + } } /// Records the reads that have started, in order. @@ -82,4 +181,26 @@ private final class PublicReadLaneSdk: PaykitSdk, @unchecked Sendable { await log.record("file:\(uri)") return Data() } + + override func paykitReceiverPaths(publicKey: String) async throws -> [String] { + await log.record("paths:\(publicKey)") + await gate.wait() + return [PaykitReceiverPath.wallet, PaykitReceiverPath.server] + } + + /// The wallet path has no marker; the server path takes private payments and payment requests. + override func paykitReceiverMarker(publicKey: String, receiverPath: String) async throws -> PaykitReceiverMarker? { + await log.record("marker:\(publicKey):\(receiverPath)") + await gate.wait() + guard receiverPath == PaykitReceiverPath.server else { return nil } + return PaykitReceiverMarker( + receiverPath: receiverPath, + capabilities: PaykitReceiverCapabilities(privatePayments: true, paymentRequests: true, receipts: false, outgoingPayments: true), + noisePublicKey: "noise" + ) + } + + override func contactRecords() async throws -> [ContactRecord] { + [] + } } From 887825b6e3dffe40496c1cbe30629acf5098cbac Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 16:04:44 +0200 Subject: [PATCH 19/79] fix: import contacts with the profiles found while preparing the import Import no longer looks every follow's profile up again, with a retry, before saving it. It saves the profiles the import screens already show. A follow whose lookup failed is still imported as a placeholder, on the wallet receiver path and without receiver discovery, since private sync discovers and merges a saved contact's receivers before it uses them. Only a failed save skips a contact, and an import stops saving once the Pubky session is reset by a sign-out or identity change. --- Bitkit/Managers/ContactsManager.swift | 139 +++++++++++++----- .../Contacts/ContactImportOverviewView.swift | 4 +- .../Contacts/ContactImportSelectView.swift | 4 +- BitkitTests/ContactsManagerTests.swift | 131 ++++++++++++++++- 4 files changed, 238 insertions(+), 40 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 1a8ad5e93..bdbe39ef2 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -116,6 +116,7 @@ struct ContactSection: Identifiable { class ContactsManager: ObservableObject { private var contactsRevision = 0 private var loadGeneration = 0 + private var sessionGeneration = 0 private let contactRecords: @Sendable () async throws -> [ContactRecord] init(contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords) { @@ -134,6 +135,8 @@ class ContactsManager: ObservableObject { /// Pending contacts discovered during import, such as pubky.app follows after Ring auth. @Published var pendingImportProfile: PubkyProfile? @Published var pendingImportContacts: [PubkyContact] = [] + /// Pending follows whose profile lookup failed, shown and imported with a placeholder profile. + private var pendingImportUnresolvedKeys: Set = [] var hasPendingImport: Bool { pendingImportProfile != nil && !pendingImportContacts.isEmpty @@ -148,6 +151,7 @@ class ContactsManager: ObservableObject { func reset() { loadGeneration += 1 + sessionGeneration += 1 contacts = [] isLoading = false hasLoaded = false @@ -159,6 +163,7 @@ class ContactsManager: ObservableObject { func clearPendingImport() { pendingImportProfile = nil pendingImportContacts = [] + pendingImportUnresolvedKeys = [] } // MARK: - Load Contacts @@ -379,27 +384,57 @@ class ContactsManager: ObservableObject { // MARK: - Import Contacts - func importContacts(publicKeys: [String]) async throws { - let prefixedKeys = Array(Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized))) + /// Saves the follows the import screens show with the profiles `prepareImport` resolved, so it looks no profile up + /// again. A follow whose lookup failed is saved as a placeholder on the wallet receiver path without discovery: + /// private sync discovers and merges a saved contact's receivers before it uses them. Only a failed save skips a + /// contact. + func importContacts(_ contactsToImport: [PubkyContact]) async throws { + try await importContacts( + contactsToImport, + discoverReceiverPaths: { try await Self.relevantReceiverPaths(for: $0, priority: .bulk) }, + saveContact: { publicKey, label, receiverPaths in + _ = try await PubkyService.saveContact( + publicKey: publicKey, + label: label, + receiverPaths: receiverPaths, + restorePrivateConnection: true + ) + } + ) + } + + /// Stops saving once `reset()` runs, so an import that outlives a sign-out or identity change saves nothing more. + func importContacts( + _ contactsToImport: [PubkyContact], + discoverReceiverPaths: @escaping @Sendable (String) async throws -> [String], + saveContact: @escaping @Sendable (_ publicKey: String, _ label: String, _ receiverPaths: [String]) async throws -> Void + ) async throws { + let session = sessionGeneration + let unresolvedKeys = pendingImportUnresolvedKeys + var seenKeys = Set() + let imports: [(contact: PubkyContact, isResolved: Bool)] = contactsToImport.compactMap { contact in + guard let key = PubkyPublicKeyFormat.normalized(contact.publicKey), seenKeys.insert(key).inserted else { return nil } + return (PubkyContact(publicKey: key, profile: contact.profile), !unresolvedKeys.contains(key)) + } let loadedResult: (contacts: [PubkyContact], failures: Int, firstError: Error?) = await withTaskGroup(of: Result.self) { group in - for key in prefixedKeys { + for (contact, isResolved) in imports { group.addTask { [self] in do { - let profile = try await resolveContactProfile(publicKey: key, includePlaceholder: true, retryTransient: true) - let receiverPaths = try await Self.relevantReceiverPaths(for: key, priority: .bulk) - _ = try await PubkyService.saveContact( - publicKey: key, - label: profile.name, - receiverPaths: receiverPaths, - restorePrivateConnection: true - ) - return .success(PubkyContact(publicKey: key, profile: profile)) + let receiverPaths = isResolved + ? try await discoverReceiverPaths(contact.publicKey) + : [PaykitReceiverPath.wallet] + guard await isCurrentSession(session) else { throw CancellationError() } + try await saveContact(contact.publicKey, contact.profile.name, receiverPaths) + return .success(contact) } catch is CancellationError { return .failure(CancellationError()) } catch { - Logger.warn("Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(key))': \(error)", context: "ContactsManager") + Logger.warn( + "Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(contact.publicKey))': \(error)", + context: "ContactsManager" + ) return .failure(error) } } @@ -423,8 +458,9 @@ class ContactsManager: ObservableObject { } try Task.checkCancellation() + guard isCurrentSession(session) else { throw CancellationError() } - if !prefixedKeys.isEmpty, loadedResult.contacts.isEmpty { + if !imports.isEmpty, loadedResult.contacts.isEmpty { throw loadedResult.firstError ?? PubkyServiceError.profileNotFound } @@ -440,6 +476,10 @@ class ContactsManager: ObservableObject { Logger.info("Imported \(newContacts.count) new contacts", context: "ContactsManager") } + private func isCurrentSession(_ session: Int) -> Bool { + session == sessionGeneration + } + // MARK: - Update Contact func updateContact(publicKey: String, name: String, bio: String, imageUrl: String?, links: [PubkyProfileLink], tags: [String]) async throws { @@ -567,60 +607,85 @@ class ContactsManager: ObservableObject { } func discoverRemoteContacts(publicKey: String) async { + await discoverRemoteContacts( + publicKey: publicKey, + fetchFollows: { try await PubkyService.getContacts(publicKey: $0) }, + fetchRemoteProfile: Self.fetchRemoteContactProfileInBulk + ) + } + + func discoverRemoteContacts( + publicKey: String, + fetchFollows: @escaping @Sendable (String) async throws -> [String], + fetchRemoteProfile: @escaping @Sendable (String) async throws -> PubkyProfile? + ) async { let prefixedKey = ensurePubkyPrefix(publicKey) do { let contactKeys = try await Task.detached { - try await PubkyService.getContacts(publicKey: prefixedKey) + try await fetchFollows(prefixedKey) }.value Logger.info("Discovered \(contactKeys.count) contacts from pubky.app", context: "ContactsManager") - let discoveryResult: (contacts: [PubkyContact], failures: Int) = await withTaskGroup(of: Result.self) { group in + let lookups = await withTaskGroup(of: FollowLookup.self) { group in for key in contactKeys { let pk = ensurePubkyPrefix(key) group.addTask { do { - let profile = try await Self.resolveContactProfile( - publicKey: pk, - includePlaceholder: true, - fetchRemoteProfile: Self.fetchRemoteContactProfileInBulk - ) - return .success(PubkyContact(publicKey: pk, profile: profile)) + let profile = try await Self.resolveContactProfile(publicKey: pk, fetchRemoteProfile: fetchRemoteProfile) + return .resolved(PubkyContact(publicKey: pk, profile: profile)) + } catch is CancellationError { + return .cancelled } catch { - return .failure(error) + return .unresolved(PubkyContact(publicKey: pk, profile: PubkyProfile.placeholder(publicKey: pk))) } } } - var results: [PubkyContact] = [] - var failures = 0 - - for await result in group { - switch result { - case let .success(contact): - results.append(contact) - case .failure: - failures += 1 - } + var results: [FollowLookup] = [] + for await lookup in group { + results.append(lookup) } + return results + } - return (results, failures) + var discovered: [PubkyContact] = [] + var unresolvedKeys = Set() + var failures = 0 + for lookup in lookups { + switch lookup { + case let .resolved(contact): + discovered.append(contact) + case let .unresolved(contact): + discovered.append(contact) + unresolvedKeys.insert(PubkyPublicKeyFormat.normalized(contact.publicKey) ?? contact.publicKey) + case .cancelled: + failures += 1 + } } - if discoveryResult.failures > 0 { - Logger.warn("Skipped \(discoveryResult.failures) remote contacts during discovery", context: "ContactsManager") + if failures > 0 { + Logger.warn("Skipped \(failures) remote contacts during discovery", context: "ContactsManager") } - pendingImportContacts = discoveryResult.contacts.sorted { + pendingImportContacts = discovered.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + pendingImportUnresolvedKeys = unresolvedKeys } catch { Logger.warn("Failed to discover remote contacts: \(error)", context: "ContactsManager") pendingImportContacts = [] + pendingImportUnresolvedKeys = [] } } + private enum FollowLookup { + case resolved(PubkyContact) + case unresolved(PubkyContact) + case cancelled + } + // MARK: - Contact Profile Resolution func fetchContactProfile(publicKey: String, includePlaceholder: Bool = false, retryTransient: Bool = false) async -> PubkyProfile? { diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index 7f5ea112a..9b6245d44 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -173,9 +173,11 @@ struct ContactImportOverviewView: View { defer { isImporting = false } do { - try await contactsManager.importContacts(publicKeys: contacts.map(\.publicKey)) + try await contactsManager.importContacts(contacts) contactsManager.clearPendingImport() navigation.path = [.payContacts] + } catch is CancellationError { + return } catch { app.toast(type: .error, title: t("contacts__import_error")) } diff --git a/Bitkit/Views/Contacts/ContactImportSelectView.swift b/Bitkit/Views/Contacts/ContactImportSelectView.swift index b0214c7a9..6ad8f9f44 100644 --- a/Bitkit/Views/Contacts/ContactImportSelectView.swift +++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift @@ -180,9 +180,11 @@ struct ContactImportSelectView: View { defer { isImporting = false } do { - try await contactsManager.importContacts(publicKeys: selected.map(\.publicKey)) + try await contactsManager.importContacts(selected) contactsManager.clearPendingImport() navigation.path = [.payContacts] + } catch is CancellationError { + return } catch { app.toast(type: .error, title: t("contacts__import_error")) } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 5aa829422..babf1acfb 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -393,7 +393,7 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(attempts, 2) } - func testImportContactProfileLookupFallsBackToPlaceholderAfterOneRetry() async throws { + func testUserInitiatedContactProfileLookupFallsBackToPlaceholderAfterOneRetry() async throws { let stub = ContactProfileFetchStub([.failure(profileTransportError)]) let profile = try await ContactsManager.resolveContactProfile( @@ -470,6 +470,91 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(manager.isLoading) } + func testImportSavesPreparedProfilesAndKeepsUnresolvedFollowsAsPlaceholders() async throws { + let manager = ContactsManager() + let placeholderName = Bitkit.PubkyProfile.placeholder(publicKey: unresolvedFollowKey).name + await manager.discoverRemoteContacts( + publicKey: "owner", + fetchFollows: { _ in [contactProfileKey, unresolvedFollowKey] }, + fetchRemoteProfile: { key in + guard key == contactProfileKey else { throw profileTransportError } + return Bitkit.PubkyProfile(publicKey: key, name: "Alice", bio: "", imageUrl: nil, links: [], status: nil) + } + ) + XCTAssertEqual(Set(manager.pendingImportContacts.map(\.displayName)), ["Alice", placeholderName]) + let stub = ImportStub() + + try await manager.importContacts( + manager.pendingImportContacts, + discoverReceiverPaths: { await stub.discover($0) }, + saveContact: { try await stub.save($0, label: $1, receiverPaths: $2) } + ) + + let discoveredKeys = await stub.discoveredKeys + XCTAssertEqual(discoveredKeys, [contactProfileKey], "Only resolved follows run receiver discovery") + let saves = await stub.saves + XCTAssertEqual(saves[contactProfileKey]?.label, "Alice") + XCTAssertEqual(saves[contactProfileKey]?.receiverPaths, [PaykitReceiverPath.wallet, PaykitReceiverPath.server]) + XCTAssertEqual(saves[unresolvedFollowKey]?.label, placeholderName) + XCTAssertEqual(saves[unresolvedFollowKey]?.receiverPaths, [PaykitReceiverPath.wallet]) + XCTAssertEqual(Set(manager.contacts.map(\.displayName)), ["Alice", placeholderName]) + } + + func testImportSkipsOnlyContactsWhoseSaveFails() async throws { + let alice = makeContact(publicKey: contactProfileKey) + let other = makeContact(publicKey: unresolvedFollowKey) + let manager = ContactsManager() + let stub = ImportStub() + await stub.failSaves(for: [unresolvedFollowKey]) + + try await manager.importContacts( + [alice, other], + discoverReceiverPaths: { await stub.discover($0) }, + saveContact: { try await stub.save($0, label: $1, receiverPaths: $2) } + ) + + XCTAssertEqual(manager.contacts.map(\.publicKey), [contactProfileKey]) + + let failingManager = ContactsManager() + await stub.failSaves(for: [contactProfileKey, unresolvedFollowKey]) + do { + try await failingManager.importContacts( + [alice, other], + discoverReceiverPaths: { await stub.discover($0) }, + saveContact: { try await stub.save($0, label: $1, receiverPaths: $2) } + ) + XCTFail("Expected an import whose every save failed to throw") + } catch { + XCTAssertTrue(failingManager.contacts.isEmpty) + } + } + + func testImportSavesNothingMoreAfterReset() async throws { + let manager = ContactsManager() + let stub = ImportStub() + await stub.holdDiscovery() + let contactsToImport = [makeContact(publicKey: contactProfileKey), makeContact(publicKey: unresolvedFollowKey)] + let importTask = Task { + try await manager.importContacts( + contactsToImport, + discoverReceiverPaths: { await stub.discover($0) }, + saveContact: { try await stub.save($0, label: $1, receiverPaths: $2) } + ) + } + while await stub.heldDiscoveryCount < 2 { + await Task.yield() + } + + manager.reset() + await stub.releaseDiscovery() + + let result = await importTask.result + XCTAssertThrowsError(try result.get()) { XCTAssertTrue($0 is CancellationError, "Expected cancellation, got \($0)") } + let saves = await stub.saves + XCTAssertTrue(saves.isEmpty) + XCTAssertTrue(manager.contacts.isEmpty) + } + func testShouldDiscardPendingImportWhenLeavingImportFlow() { XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .contacts)) XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportSelect, destination: nil)) @@ -601,6 +686,50 @@ private let contactProfileKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64 /// What the SDK returns both for a network failure and for a key with no pkarr record. private let profileTransportError = PaykitError.Transport(code: "transport_error", context: "fetch profile") +private let unresolvedFollowKey = "pubky" + String(repeating: "y", count: 52) + +/// Records an import's receiver discovery and saves, and can fail saves or hold discovery until released. +private actor ImportStub { + private(set) var discoveredKeys: [String] = [] + private(set) var saves: [String: (label: String, receiverPaths: [String])] = [:] + private var failingSaveKeys: Set = [] + private var isHoldingDiscovery = false + private var heldDiscoveries: [CheckedContinuation] = [] + + var heldDiscoveryCount: Int { + heldDiscoveries.count + } + + func failSaves(for keys: Set) { + failingSaveKeys = keys + } + + func holdDiscovery() { + isHoldingDiscovery = true + } + + func releaseDiscovery() { + isHoldingDiscovery = false + heldDiscoveries.forEach { $0.resume() } + heldDiscoveries.removeAll() + } + + func discover(_ publicKey: String) async -> [String] { + discoveredKeys.append(publicKey) + if isHoldingDiscovery { + await withCheckedContinuation { heldDiscoveries.append($0) } + } + return [PaykitReceiverPath.wallet, PaykitReceiverPath.server] + } + + func save(_ publicKey: String, label: String, receiverPaths: [String]) throws { + if failingSaveKeys.contains(publicKey) { + throw PubkyServiceError.sessionNotActive + } + saves[publicKey] = (label, receiverPaths) + } +} + /// Answers each fetch with the next scripted outcome, repeating the last one once the script runs out. private actor ContactProfileFetchStub { private(set) var attempts = 0 From 2ec181711f29cac420f71a4ca5653d76db988f25 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 16:27:10 +0200 Subject: [PATCH 20/79] fix: show saved contacts before their profiles finish loading Contacts used to show a spinner over an empty list until every contact's profile lookup had finished, which takes seconds per contact with no published profile. A load now publishes the saved records at once, using a contact's profile override, its stored Paykit profile, a profile resolved earlier this session for the same identity, or its saved label, then looks the rest up in the background on the bulk read lane and updates rows as they resolve. A failed lookup leaves its row as it is. Profiles resolved while preparing an import, importing or adding a contact seed that session cache, which a reset or another identity's load clears. Profile refreshes do not count as a change to the saved contacts, so private Paykit sync does not rerun for every row that fills in. --- Bitkit/AppScene.swift | 2 +- Bitkit/Managers/ContactsManager.swift | 245 +++++++++++++------------ BitkitTests/ContactsManagerTests.swift | 179 ++++++++++++++---- 3 files changed, 275 insertions(+), 151 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 26c1ea552..05e362ffd 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -411,7 +411,7 @@ struct AppScene: View { paykitPaymentRequestManager.clear() } } - .onReceive(contactsManager.$contacts) { contacts in + .onReceive(contactsManager.savedContactsChangedPublisher) { contacts in guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index bdbe39ef2..e3347227c 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import Paykit import SwiftUI @@ -117,14 +118,32 @@ class ContactsManager: ObservableObject { private var contactsRevision = 0 private var loadGeneration = 0 private var sessionGeneration = 0 + private var isApplyingProfileRefresh = false + private var profileRefreshTask: Task? + /// Profiles resolved this session for the owner's contacts, shown in place of stored labels while a load refreshes. + private var resolvedProfiles: [String: PubkyProfile] = [:] + private var resolvedProfilesOwner: String? private let contactRecords: @Sendable () async throws -> [ContactRecord] init(contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords) { self.contactRecords = contactRecords } + /// Profile refreshes replace rows without counting as a change to the saved contacts. @Published var contacts: [PubkyContact] = [] { - didSet { contactsRevision += 1 } + didSet { + guard !isApplyingProfileRefresh else { return } + contactsRevision += 1 + savedContactsChangedSubject.send(contacts) + } + } + + private let savedContactsChangedSubject = PassthroughSubject<[PubkyContact], Never>() + + /// The contacts after every change that may have changed the saved contact list. Profiles a background refresh + /// fills in do not emit, so work that runs per saved contact, such as private Paykit sync, does not rerun per row. + var savedContactsChangedPublisher: AnyPublisher<[PubkyContact], Never> { + savedContactsChangedSubject.eraseToAnyPublisher() } @Published var isLoading = false @@ -152,6 +171,10 @@ class ContactsManager: ObservableObject { func reset() { loadGeneration += 1 sessionGeneration += 1 + profileRefreshTask?.cancel() + profileRefreshTask = nil + resolvedProfiles = [:] + resolvedProfilesOwner = nil contacts = [] isLoading = false hasLoaded = false @@ -192,7 +215,9 @@ class ContactsManager: ObservableObject { ) } - /// A cancelled load publishes nothing: lookups it abandoned would otherwise replace loaded profiles with placeholders. + /// Publishes the saved records straight away, each with the best profile already known, then looks the remaining + /// profiles up in the background on the bulk read lane and updates rows as they resolve. A failed lookup leaves its + /// row as it is. A cancelled load publishes nothing. func loadContacts( for publicKey: String, fetchContactRecords: @escaping @Sendable () async throws -> [Paykit.ContactRecord], @@ -205,6 +230,8 @@ class ContactsManager: ObservableObject { loadGeneration += 1 let generation = loadGeneration + profileRefreshTask?.cancel() + useResolvedProfiles(of: publicKey) isLoading = true loadErrorMessage = nil defer { @@ -218,80 +245,23 @@ class ContactsManager: ObservableObject { let revision = contactsRevision do { let records = try await fetchContactRecords() - guard !Task.isCancelled else { return } - - Logger.debug("Loaded \(records.count) SDK contact records", context: "ContactsManager") - - let loadedResult: (contacts: [PubkyContact], failures: Int, - missingFailures: Int, firstError: Error?) = await withTaskGroup(of: Result.self) { group in - let overrides = Self.loadContactProfileOverrides() - for record in records { - group.addTask { - do { - let contact = try await Self.contact( - from: record, - overrides: overrides, - includePlaceholder: true, - fetchRemoteProfile: fetchRemoteProfile - ) - return .success(contact) - } catch { - Logger.warn( - "Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(record.publicKey))': \(error)", - context: "ContactsManager" - ) - return .failure(error) - } - } - } - - var results: [PubkyContact] = [] - var failures = 0 - var missingFailures = 0 - var firstError: Error? - - for await result in group { - switch result { - case let .success(contact): - results.append(contact) - case let .failure(error): - failures += 1 - if Self.isMissingContactsDataError(error) { - missingFailures += 1 - } - firstError = firstError ?? error - } - } - - return (results, failures, missingFailures, firstError) - } - guard !Task.isCancelled else { return } - + guard !Task.isCancelled, generation == loadGeneration else { return } guard contactsRevision == revision else { continue } - if !records.isEmpty, loadedResult.contacts.isEmpty { - if loadedResult.failures == loadedResult.missingFailures { - contacts = [] - hasLoaded = true - await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) - Logger.info("Contacts storage entries were missing, treating list as empty", context: "ContactsManager") - return - } - throw loadedResult.firstError ?? PubkyServiceError.profileNotFound - } + Logger.debug("Loaded \(records.count) SDK contact records", context: "ContactsManager") - contacts = loadedResult.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + let overrides = Self.loadContactProfileOverrides() + contacts = records.map { savedContact(from: $0, overrides: overrides) } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } hasLoaded = true + refreshContactProfiles( + for: records.filter { $0.profile == nil && overrides[Self.contactKey(for: $0)] == nil }, + generation: generation, + fetchRemoteProfile: fetchRemoteProfile + ) await PrivatePaykitService.shared .pruneUnsavedContactState(savedPublicKeys: records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) }) - if loadedResult.failures > 0 { - Logger.warn( - "Skipped \(loadedResult.failures) unreadable contacts while loading list", - context: "ContactsManager" - ) - } - Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager") return } catch { @@ -314,6 +284,86 @@ class ContactsManager: ObservableObject { } } + private func savedContact(from record: Paykit.ContactRecord, overrides: [String: PubkyProfileData]) -> PubkyContact { + let publicKey = Self.contactKey(for: record) + if let override = overrides[publicKey] { + return PubkyContact(publicKey: publicKey, profile: override.toProfile(publicKey: publicKey)) + } + if let profile = record.profile { + return PubkyContact( + publicKey: publicKey, + profile: PubkyProfile(publicKey: publicKey, paykitProfile: profile).withNameFallback(record.label) + ) + } + if let profile = resolvedProfiles[publicKey] { + return PubkyContact(publicKey: publicKey, profile: profile.withNameFallback(record.label)) + } + let label = record.label.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } + return PubkyContact(publicKey: publicKey, profile: PubkyProfile.forDisplay(publicKey: publicKey, name: label, imageUrl: nil)) + } + + private func refreshContactProfiles( + for records: [Paykit.ContactRecord], + generation: Int, + fetchRemoteProfile: @escaping @Sendable (String) async throws -> PubkyProfile? + ) { + let lookups = records.map { (publicKey: Self.contactKey(for: $0), label: $0.label) } + guard !lookups.isEmpty else { + profileRefreshTask = nil + return + } + + profileRefreshTask = Task { [weak self] in + await withTaskGroup(of: (publicKey: String, profile: PubkyProfile?).self) { group in + for lookup in lookups { + group.addTask { + let profile = try? await Self.resolveContactProfile(publicKey: lookup.publicKey, fetchRemoteProfile: fetchRemoteProfile) + return (lookup.publicKey, profile?.withNameFallback(lookup.label)) + } + } + + for await result in group { + guard let profile = result.profile else { continue } + self?.applyRefreshedProfile(profile, for: result.publicKey, generation: generation) + } + } + } + } + + /// Runs on the refresh task, so `Task.isCancelled` is that refresh's cancellation. + private func applyRefreshedProfile(_ profile: PubkyProfile, for publicKey: String, generation: Int) { + guard generation == loadGeneration, !Task.isCancelled else { return } + rememberResolvedProfile(profile, for: publicKey) + guard Self.loadContactProfileOverrides()[publicKey] == nil, + let index = contacts.firstIndex(where: { $0.publicKey == publicKey }) + else { return } + + var refreshed = contacts + refreshed[index] = PubkyContact(publicKey: publicKey, profile: profile) + refreshed.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + isApplyingProfileRefresh = true + contacts = refreshed + isApplyingProfileRefresh = false + } + + private func useResolvedProfiles(of ownerPublicKey: String) { + let owner = PubkyPublicKeyFormat.normalized(ownerPublicKey) ?? ownerPublicKey + guard owner != resolvedProfilesOwner else { return } + resolvedProfiles = [:] + resolvedProfilesOwner = owner + } + + private func rememberResolvedProfile(_ profile: PubkyProfile, for publicKey: String) { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + resolvedProfiles[normalizedKey] = profile + } + + #if DEBUG + func waitForProfileRefreshForTesting() async { + await profileRefreshTask?.value + } + #endif + // MARK: - Add Contact func addContact(publicKey: String, existingProfile: PubkyProfile? = nil, ownPublicKey: String? = nil) async throws { @@ -353,6 +403,7 @@ class ContactsManager: ObservableObject { Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") + rememberResolvedProfile(profile, for: prefixedKey) let contact = PubkyContact(publicKey: prefixedKey, profile: profile) contacts.append(contact) contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } @@ -418,7 +469,7 @@ class ContactsManager: ObservableObject { } let loadedResult: (contacts: [PubkyContact], failures: Int, - firstError: Error?) = await withTaskGroup(of: Result.self) { group in + firstError: Error?) = await withTaskGroup(of: Result<(PubkyContact, Bool), Error>.self) { group in for (contact, isResolved) in imports { group.addTask { [self] in do { @@ -427,7 +478,7 @@ class ContactsManager: ObservableObject { : [PaykitReceiverPath.wallet] guard await isCurrentSession(session) else { throw CancellationError() } try await saveContact(contact.publicKey, contact.profile.name, receiverPaths) - return .success(contact) + return .success((contact, isResolved)) } catch is CancellationError { return .failure(CancellationError()) } catch { @@ -446,8 +497,11 @@ class ContactsManager: ObservableObject { for await result in group { switch result { - case let .success(contact): + case let .success((contact, isResolved)): results.append(contact) + if isResolved { + rememberResolvedProfile(contact.profile, for: contact.publicKey) + } case let .failure(error): failures += 1 firstError = firstError ?? error @@ -591,6 +645,7 @@ class ContactsManager: ObservableObject { @discardableResult func prepareImport(profile: PubkyProfile?, publicKey: String) async -> Bool { clearPendingImport() + useResolvedProfiles(of: publicKey) await discoverRemoteContacts(publicKey: publicKey) guard !pendingImportContacts.isEmpty else { @@ -657,6 +712,7 @@ class ContactsManager: ObservableObject { switch lookup { case let .resolved(contact): discovered.append(contact) + rememberResolvedProfile(contact.profile, for: contact.publicKey) case let .unresolved(contact): discovered.append(contact) unresolvedKeys.insert(PubkyPublicKeyFormat.normalized(contact.publicKey) ?? contact.publicKey) @@ -811,49 +867,8 @@ class ContactsManager: ObservableObject { private nonisolated static let contactProfileOverridesKey = "pubkyContactProfileOverrides" - private nonisolated static func contact( - from record: Paykit.ContactRecord, - overrides: [String: PubkyProfileData], - includePlaceholder: Bool, - fetchRemoteProfile: @Sendable (String) async throws -> PubkyProfile? - ) async throws -> PubkyContact { - let prefixedKey = PubkyPublicKeyFormat.normalized(record.publicKey) ?? ensurePubkyPrefix(record.publicKey) - - if let override = overrides[prefixedKey] { - return PubkyContact(publicKey: prefixedKey, profile: override.toProfile(publicKey: prefixedKey)) - } - - if let profile = record.profile { - let contactProfile = PubkyProfile(publicKey: prefixedKey, paykitProfile: profile) - .withNameFallback(record.label) - return PubkyContact(publicKey: prefixedKey, profile: contactProfile) - } - - do { - let profile = try await resolveContactProfile( - publicKey: prefixedKey, - includePlaceholder: includePlaceholder, - fetchRemoteProfile: fetchRemoteProfile - ) - .withNameFallback(record.label) - return PubkyContact( - publicKey: prefixedKey, - profile: profile - ) - } catch { - if !includePlaceholder || error is CancellationError { - throw error - } - } - - if includePlaceholder { - return PubkyContact( - publicKey: prefixedKey, - profile: PubkyProfile.forDisplay(publicKey: prefixedKey, name: record.label, imageUrl: nil) - ) - } - - throw PubkyServiceError.profileNotFound + private nonisolated static func contactKey(for record: Paykit.ContactRecord) -> String { + PubkyPublicKeyFormat.normalized(record.publicKey) ?? ensurePubkyPrefix(record.publicKey) } private nonisolated static func loadContactProfileOverrides() -> [String: PubkyProfileData] { diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index babf1acfb..866008286 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -109,6 +109,15 @@ final class ContactsManagerTests: XCTestCase { ) } + private func unprofiledRecord(key: String, label: String?) -> ContactRecord { + ContactRecord( + publicKey: key, receiverPaths: [PaykitReceiverPath.wallet], label: label, profile: nil, + profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", + publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, + publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil + ) + } + func testPubkyPublicKeyFormatNormalizesPrefixedAndUnprefixedKeys() { let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" let prefixedKey = "pubky\(rawKey)" @@ -429,45 +438,109 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(attempts, 1) } - func testCancelledContactsReloadKeepsLoadedContacts() async throws { + func testLoadPublishesSavedContactsBeforeTheirProfilesResolve() async throws { let manager = ContactsManager() - manager.contacts = [makeContact(publicKey: contactProfileKey)] - let hasLoadedBefore = manager.hasLoaded - let record = ContactRecord( - publicKey: contactProfileKey, - receiverPaths: [], - label: "Label only", - profile: nil, - profileFetchedAt: nil, - createdAt: "", - updatedAt: "", - publicContactMarkerStatus: .notPublished, - publicContactMarkerReceiverPath: nil, - publicContactPublishedAt: nil, - publicContactRemovedAt: nil, - publicContactLastError: nil - ) - let lookupStarted = expectation(description: "profile lookup started") - - let reload = Task { - try await manager.loadContacts( - for: contactProfileKey, - fetchContactRecords: { [record] }, - fetchRemoteProfile: { _ in - lookupStarted.fulfill() - try await Task.sleep(nanoseconds: 60_000_000_000) - return nil - } - ) + let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + await lookups.hold() + let stored = contactRecord(key: unresolvedFollowKey, name: "Bob") + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only"), stored] + + let loadReturned = expectation(description: "Load returned while the profile lookup was still running") + let load = Task { + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + loadReturned.fulfill() } - await fulfillment(of: [lookupStarted], timeout: 2) - reload.cancel() - try await reload.value + await fulfillment(of: [loadReturned], timeout: 2) - XCTAssertEqual(manager.contacts.map(\.profile.name), ["Alice"]) - XCTAssertEqual(manager.hasLoaded, hasLoadedBefore) - XCTAssertNil(manager.loadErrorMessage) + XCTAssertTrue(manager.hasLoaded) XCTAssertFalse(manager.isLoading) + XCTAssertEqual(Set(manager.contacts.map(\.displayName)), ["Label only", "Bob"]) + + await lookups.release() + try await load.value + await manager.waitForProfileRefreshForTesting() + XCTAssertEqual(Set(manager.contacts.map(\.displayName)), ["Alice", "Bob"]) + let fetchedKeys = await lookups.fetchedKeys + XCTAssertEqual(fetchedKeys, [contactProfileKey], "A stored profile is not looked up again") + } + + func testReloadShowsProfilesResolvedEarlierThisSessionWhenItsLookupsFail() async throws { + let manager = ContactsManager() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + let resolving = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await resolving.fetch($0) }) + await manager.waitForProfileRefreshForTesting() + XCTAssertEqual(manager.contacts.map(\.displayName), ["Alice"]) + + let failing = HeldProfileLookups(profiles: [:]) + await failing.hold() + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await failing.fetch($0) }) + XCTAssertEqual(manager.contacts.map(\.displayName), ["Alice"], "A reload starts from the profile resolved earlier") + + await failing.release() + await manager.waitForProfileRefreshForTesting() + XCTAssertEqual(manager.contacts.map(\.displayName), ["Alice"], "A failed lookup leaves the row as it is") + XCTAssertTrue(manager.hasLoaded) + XCTAssertNil(manager.loadErrorMessage) + } + + func testResolvedProfilesAreForgottenOnResetAndForAnotherOwner() async throws { + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + let resolving = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + let failing = HeldProfileLookups(profiles: [:]) + let manager = ContactsManager() + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await resolving.fetch($0) }) + await manager.waitForProfileRefreshForTesting() + XCTAssertEqual(manager.contacts.map(\.displayName), ["Alice"]) + + try await manager.loadContacts(for: "another-owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await failing.fetch($0) }) + XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "Another owner's load must not show the first owner's profiles") + + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await resolving.fetch($0) }) + await manager.waitForProfileRefreshForTesting() + manager.reset() + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await failing.fetch($0) }) + XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "Reset forgets the profiles resolved before it") + } + + func testProfileRefreshStartedBeforeResetIsIgnored() async throws { + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + let held = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + await held.hold() + let failing = HeldProfileLookups(profiles: [:]) + let manager = ContactsManager() + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await held.fetch($0) }) + while await held.heldCount < 1 { + await Task.yield() + } + + manager.reset() + try await manager.loadContacts(for: "next-owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await failing.fetch($0) }) + await held.release() + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "A refresh from before the reset must not update rows") + + try await manager.loadContacts(for: "next-owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await failing.fetch($0) }) + XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "A refresh from before the reset must not seed resolved profiles") + } + + func testProfileRefreshDoesNotReportASavedContactsChange() async throws { + let manager = ContactsManager() + var changes: [Set] = [] + let subscription = manager.savedContactsChangedPublisher.sink { changes.append(Set($0.map(\.publicKey))) } + defer { subscription.cancel() } + let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice", unresolvedFollowKey: "Bob"]) + let records = [unprofiledRecord(key: contactProfileKey, label: nil), unprofiledRecord(key: unresolvedFollowKey, label: nil)] + + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + await manager.waitForProfileRefreshForTesting() + + XCTAssertEqual(Set(manager.contacts.map(\.displayName)), ["Alice", "Bob"]) + XCTAssertEqual(changes, [[contactProfileKey, unresolvedFollowKey]], "Only the load itself may report a change") + + let addedKey = "pubky" + String(repeating: "r", count: 52) + manager.contacts.append(makeContact(publicKey: addedKey)) + XCTAssertEqual(changes.last, [contactProfileKey, unresolvedFollowKey, addedKey]) } func testImportSavesPreparedProfilesAndKeepsUnresolvedFollowsAsPlaceholders() async throws { @@ -688,6 +761,42 @@ private let profileTransportError = PaykitError.Transport(code: "transport_error private let unresolvedFollowKey = "pubky" + String(repeating: "y", count: 52) +/// Answers profile lookups with the scripted names, failing for any other key like a key with no profile, and can hold +/// lookups until released. +private actor HeldProfileLookups { + private let profiles: [String: String] + private(set) var fetchedKeys: [String] = [] + private var isHolding = false + private var held: [CheckedContinuation] = [] + + var heldCount: Int { + held.count + } + + init(profiles: [String: String]) { + self.profiles = profiles + } + + func hold() { + isHolding = true + } + + func release() { + isHolding = false + held.forEach { $0.resume() } + held.removeAll() + } + + func fetch(_ publicKey: String) async throws -> Bitkit.PubkyProfile? { + fetchedKeys.append(publicKey) + if isHolding { + await withCheckedContinuation { held.append($0) } + } + guard let name = profiles[publicKey] else { throw profileTransportError } + return Bitkit.PubkyProfile(publicKey: publicKey, name: name, bio: "", imageUrl: nil, links: [], status: nil) + } +} + /// Records an import's receiver discovery and saves, and can fail saves or hold discovery until released. private actor ImportStub { private(set) var discoveredKeys: [String] = [] From c9ba6d4ea3139fb381030c62c3c4390f3979ee27 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 16:29:35 +0200 Subject: [PATCH 21/79] fix: disable the import buttons while a contact import runs The import already runs on its own task, so leaving the import screens does not stop it. Select stayed enabled while Import All ran, though, and each screen only knew about its own import, so a second import of the same follows could start alongside the first. The contacts manager now reports whether an import is running, and both import screens disable Select, Import All and Continue until it finishes. --- Bitkit/Managers/ContactsManager.swift | 9 +++++++ .../Contacts/ContactImportOverviewView.swift | 13 +++++----- .../Contacts/ContactImportSelectView.swift | 6 +---- BitkitTests/ContactsManagerTests.swift | 24 +++++++++++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index e3347227c..b27857752 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -148,6 +148,9 @@ class ContactsManager: ObservableObject { @Published var isLoading = false @Published var hasLoaded = false + /// An import runs on its own task and outlives the import screens, which read this to keep a second import from starting. + @Published private(set) var isImportingContacts = false + private var activeImportCount = 0 @Published var loadErrorMessage: String? @Published var shouldOpenAddContactSheet = false @@ -462,6 +465,12 @@ class ContactsManager: ObservableObject { ) async throws { let session = sessionGeneration let unresolvedKeys = pendingImportUnresolvedKeys + activeImportCount += 1 + isImportingContacts = true + defer { + activeImportCount -= 1 + isImportingContacts = activeImportCount > 0 + } var seenKeys = Set() let imports: [(contact: PubkyContact, isResolved: Bool)] = contactsToImport.compactMap { contact in guard let key = PubkyPublicKeyFormat.normalized(contact.publicKey), seenKeys.insert(key).inserted else { return nil } diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index 9b6245d44..81eed8e67 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -9,8 +9,6 @@ struct ContactImportOverviewView: View { @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var pubkyProfile: PubkyProfileManager - @State private var isImporting = false - private enum AvatarLayout { static let size: CGFloat = 32 static let overlap: CGFloat = 8 @@ -151,14 +149,18 @@ struct ContactImportOverviewView: View { private var buttonBar: some View { HStack(spacing: 16) { - CustomButton(title: t("contacts__import_select"), variant: .secondary) { + CustomButton( + title: t("contacts__import_select"), + variant: .secondary, + isDisabled: contactsManager.isImportingContacts + ) { navigation.navigate(.contactImportSelect) } .accessibilityIdentifier("ContactImportOverviewSelect") CustomButton( title: t("contacts__import_all"), - isLoading: isImporting + isLoading: contactsManager.isImportingContacts ) { await importAllContacts() } @@ -169,9 +171,6 @@ struct ContactImportOverviewView: View { // MARK: - Actions private func importAllContacts() async { - isImporting = true - defer { isImporting = false } - do { try await contactsManager.importContacts(contacts) contactsManager.clearPendingImport() diff --git a/Bitkit/Views/Contacts/ContactImportSelectView.swift b/Bitkit/Views/Contacts/ContactImportSelectView.swift index 6ad8f9f44..9232bd947 100644 --- a/Bitkit/Views/Contacts/ContactImportSelectView.swift +++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift @@ -8,7 +8,6 @@ struct ContactImportSelectView: View { @EnvironmentObject var contactsManager: ContactsManager @State private var selectedKeys: Set = [] - @State private var isImporting = false var body: some View { VStack(spacing: 0) { @@ -135,7 +134,7 @@ struct ContactImportSelectView: View { CustomButton( title: t("common__continue"), - isLoading: isImporting + isLoading: contactsManager.isImportingContacts ) { await importSelectedContacts() } @@ -176,9 +175,6 @@ struct ContactImportSelectView: View { return } - isImporting = true - defer { isImporting = false } - do { try await contactsManager.importContacts(selected) contactsManager.clearPendingImport() diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 866008286..f51ef3a68 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -628,6 +628,30 @@ final class ContactsManagerTests: XCTestCase { XCTAssertTrue(manager.contacts.isEmpty) } + func testImportIsReportedAsRunningUntilItFinishes() async { + let manager = ContactsManager() + let stub = ImportStub() + await stub.holdDiscovery() + await stub.failSaves(for: [contactProfileKey]) + XCTAssertFalse(manager.isImportingContacts) + + let importTask = Task { + try await manager.importContacts( + [makeContact(publicKey: contactProfileKey)], + discoverReceiverPaths: { await stub.discover($0) }, + saveContact: { try await stub.save($0, label: $1, receiverPaths: $2) } + ) + } + while await stub.heldDiscoveryCount < 1 { + await Task.yield() + } + XCTAssertTrue(manager.isImportingContacts, "The import screens disable Select and Import All while this is set") + + await stub.releaseDiscovery() + _ = await importTask.result + XCTAssertFalse(manager.isImportingContacts, "A failed import must not leave the import screens disabled") + } + func testShouldDiscardPendingImportWhenLeavingImportFlow() { XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .contacts)) XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportSelect, destination: nil)) From 26d21e154c40663c06f7bb3a675e9ce1977ad1aa Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 16:29:41 +0200 Subject: [PATCH 22/79] docs: add contact import and contacts list loading journeys Adds journeys for importing a Ring identity's follows and for Contacts showing saved contacts while their profiles load, documents them in the pubky-profile suite README, and extends the changelog fragment to cover the faster import and contacts list. --- changelog.d/next/854.fixed.md | 2 +- journeys/pubky-profile/README.md | 41 ++++++++++++++++--- journeys/pubky-profile/contact-import.xml | 29 +++++++++++++ .../pubky-profile/contacts-list-loading.xml | 28 +++++++++++++ 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 journeys/pubky-profile/contact-import.xml create mode 100644 journeys/pubky-profile/contacts-list-loading.xml diff --git a/changelog.d/next/854.fixed.md b/changelog.d/next/854.fixed.md index 40e6077c5..e9a0a6ef7 100644 --- a/changelog.d/next/854.fixed.md +++ b/changelog.d/next/854.fixed.md @@ -1 +1 @@ -Pubky profiles load faster: Pubky Ring identities appear at once, adopting one no longer waits on other lookups, and the profile screen shows your cached name and avatar while it loads. +Pubky profiles and contacts load faster: Pubky Ring identities appear at once, adopting one no longer waits on other lookups, the profile screen shows your cached name and avatar while it loads, importing contacts no longer looks every profile up again, and Contacts shows your saved contacts straight away while their profiles load. diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index c0ec88b99..271e10581 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -1,7 +1,8 @@ # Pubky profile loading -This suite covers what Profile and the Pubky Ring choice screen show while their profiles are still -loading from the network. Where Android differs, see [iOS vs Android](#ios-vs-android). +This suite covers what Profile, the Pubky Ring choice screen, the contact import and Contacts show +while profiles are still loading from the network. Where Android differs, see +[iOS vs Android](#ios-vs-android). - `cached-profile-header.xml` opens Profile straight after a relaunch, while the signed-in profile is still loading. Profile shows the name cached from the last load, read-only, then swaps in the full @@ -12,10 +13,18 @@ loading from the network. Where Android differs, see [iOS vs Android](#ios-vs-an identity has no profile. A lookup for an identity with no profile can take several seconds to give up. Tapping a row adopts that identity: only the tapped row shows a spinner, and every row is disabled until adoption finishes. +- `contact-import.xml` adopts a Ring identity that follows other pubkys and imports its follows with + Import All. The import saves the profiles the overview already looked up instead of looking every + follow up again, so it takes seconds rather than tens of seconds. A follow with no published + profile is imported under its truncated key. While the import runs, Select and Import All are + disabled, and leaving the screen does not stop it. +- `contacts-list-loading.xml` relaunches and opens Contacts. The saved contacts show straight away + under the names they were saved with, and each fills in its profile name and avatar as its lookup + finishes. Reopening Contacts in the same session shows the profiles already found straight away. ## Preconditions -Both journeys need an onboarded E2E Bitkit build with Paykit UI enabled. Use the test simulator UDID +Every journey needs an onboarded E2E Bitkit build with Paykit UI enabled. Use the test simulator UDID for ``. **Cached profile header.** A Bitkit-generated Pubky identity whose profile has a name, as in the @@ -36,11 +45,22 @@ header. Without Pubky Ring, run the steps as a manual test and name the missing capability in the PR. Tapping a row signs in with that Ring identity, so use identities you are happy to adopt. +**Contact import.** The Ring setup above, with an identity that follows at least five pubkys on +pubky.app, at least one of them with no published profile, and no Pubky identity in Bitkit. The +journey saves the follows as contacts; sign out in Bitkit before running it again. + +**Contacts list loading.** A Pubky identity with at least five saved contacts, at least one with a +published profile name and one with no published profile. Running the contact import journey first +leaves exactly that. + ## Timing -Both loading states can finish faster than a snapshot round trip on a warm network. Each journey -reports that ("already loaded", "already resolved" or "already adopted") rather than failing. The -cached-header journey repeats the relaunch once, and the ring journey continues. +Every loading state here can finish faster than a snapshot round trip on a warm network. Each +journey reports that ("already loaded", "already resolved", "already adopted" or "already imported") +rather than failing. The cached-header journey repeats the relaunch once, and the others continue. + +A follow or contact with no published profile is the slow case: its lookup can take several seconds +to give up, and before this change the import and Contacts both waited for every such lookup. Opening Profile in the first few seconds after launch can show a bare spinner, with no "Profile" title, before the cached header. That spinner is the Pubky initialization wait in `MainNavView`. The @@ -53,6 +73,10 @@ header showed. - **Profile while loading.** iOS shows the cached name and avatar read-only under `ProfileCachedHeader`, with no edit, copy, share, QR code or tag controls, and then the full profile under `ProfileViewName`. +- **Contact import and Contacts list.** These two journeys are new on iOS. The import overview, + selection and Contacts identifiers already match Android (`ContactImportOverviewSelect`, + `ContactImportOverviewImportAll`, `ContactImportSelectContinue`, `Contact_`). iOS has always + kept a follow with no published profile in the import; it shows under its truncated key. - **Ring choice rows.** Android names the rows and their lookup spinners differently; see [Identifiers](../README.md#identifiers). iOS keeps the rows up, spins only the tapped row, and tags each row `PubkyChoiceRing_`, using the bare z32 key without the `pubky` prefix. @@ -71,3 +95,8 @@ header showed. `PubkyChoiceRingLookup_`, and the create option `PubkyChoiceCreate`. - After adopting: the contact import overview `ContactImportOverviewProfile`, or Pay Contacts `PayContactsContinue`. +- Contact import: overview summary `ContactImportOverviewSummary`, Select `ContactImportOverviewSelect` + and Import All `ContactImportOverviewImportAll`; selection rows `ContactImportSelect_` and + Continue `ContactImportSelectContinue`. +- Contacts: the menu button `HeaderMenu` and drawer item `DrawerContacts`; contact rows + `Contact_`, using the full key with its `pubky` prefix. diff --git a/journeys/pubky-profile/contact-import.xml b/journeys/pubky-profile/contact-import.xml new file mode 100644 index 000000000..60d3d5578 --- /dev/null +++ b/journeys/pubky-profile/contact-import.xml @@ -0,0 +1,29 @@ + + + After adopting a Pubky Ring identity that follows other pubkys on pubky.app, the contact import + overview lists those follows. Import All saves every follow with the profile the overview + already looked up, without looking each one up again, so it finishes in a few seconds. A follow + with no published profile is imported too, under its truncated key. While the import runs, Select + and Import All are disabled, and leaving the screen does not stop the import. + + Precondition (not a listed capability): Pubky Ring installed on the same simulator or device and + signed by the same Apple team, with an identity that follows at least five pubkys on pubky.app, + at least one of them with no published profile. An onboarded E2E Bitkit build with Paykit UI + enabled and no Pubky identity in Bitkit. The journey adopts that identity and saves its follows + as contacts, so sign out of it in Bitkit before running the journey again. See README.md. + + A warm network can finish the import before the screen is inspected; report "already imported" + rather than failing. + + + Tap the header profile button (id "ProfileButton"); if the profile intro (id "ProfileIntro") shows, tap Continue (id "ProfileIntro-button") + Tap the Ring row (id "PubkyChoiceRing_<pubky>") of the identity that follows other pubkys + Wait for the contact import overview (id "ContactImportOverviewProfile", predicate exists) and note the number of friends the summary (id "ContactImportOverviewSummary") shows + Tap Import All (id "ContactImportOverviewImportAll") and note the time + While Import All shows its spinner, verify Select (id "ContactImportOverviewSelect") is disabled: tapping it does not open the selection screen. If Pay Contacts (id "PayContactsContinue") already shows, the import finished first: report "already imported" and continue + Wait for Pay Contacts (id "PayContactsContinue", predicate exists) and report how long the import took from the tap + Tap Continue (id "PayContactsContinue") and wait for Profile to open + Tap the menu button (id "HeaderMenu"), then Contacts (id "DrawerContacts"); if the contacts intro shows, tap its continue button + Verify Contacts lists one row (id "Contact_<pubky>") for each of the friends the overview counted, and that a follow with no published profile shows its truncated key as its name + + diff --git a/journeys/pubky-profile/contacts-list-loading.xml b/journeys/pubky-profile/contacts-list-loading.xml new file mode 100644 index 000000000..0ec8057b6 --- /dev/null +++ b/journeys/pubky-profile/contacts-list-loading.xml @@ -0,0 +1,28 @@ + + + Contacts shows the saved contacts as soon as it opens after a relaunch, under the names they were + saved with, and fills in each contact's profile name and avatar as its lookup finishes. It no + longer shows a spinner over an empty list until every lookup has finished, and a lookup that + fails leaves the row as it is. Within the session, reopening Contacts shows the profiles already + found straight away. + + Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Pubky identity with at + least five saved contacts, at least one of them with a published profile name and one with no + published profile. The contact import journey leaves exactly that. Use the test simulator UDID + for <UDID>. + + A warm network can resolve every profile before the first snapshot; report "already resolved" + rather than failing. See README.md. + + + Run `xcrun simctl terminate <UDID> to.bitkit` + Run `xcrun simctl launch <UDID> to.bitkit` + If the PIN screen appears, unlock the wallet + Tap the menu button (id "HeaderMenu"), then Contacts (id "DrawerContacts") + Verify the list shows one row (id "Contact_<pubky>") per saved contact, each with a name, and no spinner over an empty list. A contact whose profile has not loaded yet shows the name it was saved with, or its truncated key + Wait up to a minute and verify each contact with a published profile now shows its profile name, and its avatar if it has one. If every row already did, report "already resolved" + Verify a contact with no published profile still shows the name it had when the list opened + Tap a contact's row and verify its contact screen opens with that contact's name, then go back + Verify Contacts shows every row straight away with the profile names it showed before, with no row back on a truncated key + + From 178d22bc19d981c2654944d6f0f2c447b0525b02 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 16:30:35 +0200 Subject: [PATCH 23/79] docs: note the contact import journeys started on iOS --- journeys/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/journeys/README.md b/journeys/README.md index 948d5a75c..91512aad6 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -188,6 +188,9 @@ iOS-only — a Bitkit-specific OS handoff into watch-only consent, with no Andro `profile/signup-create-profile.xml` is currently iOS-only. It covers the iOS Profile Error regression after staging signup. Android also supports this signup flow, but this journey has not been ported there. +`pubky-profile/contact-import.xml` and `pubky-profile/contacts-list-loading.xml` started on iOS. They +use identifiers Android already shares, so they can be ported under the same names. + A journey is a shared spec, so a behaviour that is meant to match Android can be checked by running the same file on both sides: `xcodebuildmcp` here, the `android` CLI against a `bitkit-android` checkout there (`android layout` is the `snapshot-ui` equivalent). A disagreement is worth writing From d8d5ee7eedf3e6f4589ec71b934bc78058c488ff Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:27:45 +0200 Subject: [PATCH 24/79] fix: check a contact's payment request support on the interactive read lane --- .../PaykitPaymentRequestService.swift | 16 +++++--- Bitkit/Services/PubkyService.swift | 6 +-- .../PaykitPaymentRequestServiceTests.swift | 40 ++++++++++++++++++- BitkitTests/PaykitPublicReadLaneTests.swift | 2 +- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 11bba9277..fb8013291 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -481,7 +481,7 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] func identityStatus() async throws -> Paykit.IdentityStatus? func linkedPeers() async throws -> [Paykit.LinkedPeerRecord] - func paymentRequestReceiverPaths(publicKey: String) async throws -> [String] + func paymentRequestReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority) async throws -> [String] func proposePaymentRequest( counterparty: String, counterpartyReceiverPath: String, @@ -594,10 +594,13 @@ struct PaykitPaymentRequestService { } /// Keeps a previously eligible target when its capability lookup fails, so a transient transport error does not hide it. + /// Checks run one at a time on the interactive read lane, for a user waiting on them; the background refresh of every + /// saved contact passes `.bulk`. func discoverEligibleTargets( savedPublicKeys: [String], expectedIdentity: String, - previousTargets: [PaykitPaymentRequestTarget] = [] + previousTargets: [PaykitPaymentRequestTarget] = [], + priority: PaykitPublicReadPriority = .interactive ) async throws -> PaykitPaymentRequestTargetDiscovery { let unavailable = PaykitPaymentRequestTargetDiscovery(targets: [], isComplete: true) guard isPrivatePaymentPublishingEnabled(), !Self.acceptedPaymentEndpointIdentifiers().isEmpty else { return unavailable } @@ -626,7 +629,7 @@ struct PaykitPaymentRequestService { let capablePaths: [String] do { try Task.checkCancellation() - capablePaths = try await sdk.paymentRequestReceiverPaths(publicKey: publicKey) + capablePaths = try await sdk.paymentRequestReceiverPaths(publicKey: publicKey, priority: priority) } catch is CancellationError { throw CancellationError() } catch { @@ -1199,7 +1202,8 @@ final class PaykitPaymentRequestManager { let discovery = try await service.discoverEligibleTargets( savedPublicKeys: savedPublicKeys, expectedIdentity: activeIdentity, - previousTargets: eligibleTargets + previousTargets: eligibleTargets, + priority: .bulk ) guard generation == eligibilityGeneration, currentStateGeneration == stateGeneration, @@ -1292,8 +1296,8 @@ final class PaykitPaymentRequestManager { } /// Returns the known target at once, otherwise waits at most `timeout` for a refresh without blocking on a slow SDK call. - /// The lookup shares the SDK lock with payment resolution, so a contact checked in the last 30 seconds is not looked up again, - /// and a lookup that outlives the timeout is cancelled. + /// The lookup reads linked peers under the SDK lock and the contact's capabilities on the interactive read lane, so a contact + /// checked in the last 30 seconds is not looked up again, and a lookup that outlives the timeout is cancelled. func eligibleTarget(publicKey: String, waitingAtMost timeout: Duration) async -> PaykitPaymentRequestTarget? { if let target = eligibleTarget(publicKey: publicKey) { return target diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index b226a3319..923e2b5bf 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -801,11 +801,11 @@ actor PaykitSdkService { } } - /// Public reads on the bulk lane, so it never holds `operationLock`. An abandoned eligibility check leaves the read + /// Public reads on the read lane, so it never holds `operationLock`. An abandoned eligibility check leaves the read /// queue at once and stops before its next read. - func paymentRequestReceiverPaths(publicKey: String) async throws -> [String] { + func paymentRequestReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority) async throws -> [String] { try Task.checkCancellation() - return try await withPublicRead(priority: .bulk) { sdk in + return try await withPublicRead(priority: priority) { sdk in let paths = try await sdk.paykitReceiverPaths(publicKey: publicKey) var capablePaths = Set() diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 348521527..a1e043850 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3258,6 +3258,38 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } + func testOnlyTheRefreshOfEverySavedContactReadsCapabilitiesInBulk() async throws { + let savedKey = "pubky\(String(repeating: "y", count: 52))" + let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", + counterparty: savedKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee, + expiresAt: timestamp(expiresAt) + )) + let manager = paymentRequestManager(sdk: sdk) + + await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) + let target = await manager.refreshEligibleTarget(publicKey: savedKey) + _ = try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "Coffee", expiresAt: expiresAt), + to: XCTUnwrap(target) + ) + + let priorities = await sdk.receiverPathReadPriorities() + XCTAssertEqual( + priorities, + [.bulk, .interactive, .interactive], + "Contact detail's Pay check and a proposal must not queue behind the Contacts list's bulk reads" + ) + } + func testSingleEligibilityRefreshRemovesContactThatIsNoLongerLinked() async { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) @@ -4030,6 +4062,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { private var linkedPeersError: PaymentRequestSdkMockError? private var linkedPeersCallCount = 0 private var receiverPathLookupCount = 0 + private var receiverPathLookupPriorities: [PaykitPublicReadPriority] = [] private var failingReceiverPathKeys: Set = [] private var proposalResult: PaymentRequestRecord? private var uploadCount = 0 @@ -4123,8 +4156,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { return snapshot } - func paymentRequestReceiverPaths(publicKey: String) throws -> [String] { + func paymentRequestReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority) throws -> [String] { receiverPathLookupCount += 1 + receiverPathLookupPriorities.append(priority) if failingReceiverPathKeys.contains(publicKey) { throw PaymentRequestSdkMockError.receive } @@ -4348,6 +4382,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { receiverPathLookupCount } + func receiverPathReadPriorities() -> [PaykitPublicReadPriority] { + receiverPathLookupPriorities + } + func setLinkedPeersError(_ error: PaymentRequestSdkMockError?) { linkedPeersError = error } diff --git a/BitkitTests/PaykitPublicReadLaneTests.swift b/BitkitTests/PaykitPublicReadLaneTests.swift index ecae2bd0e..3e4164cf3 100644 --- a/BitkitTests/PaykitPublicReadLaneTests.swift +++ b/BitkitTests/PaykitPublicReadLaneTests.swift @@ -49,7 +49,7 @@ final class PaykitPublicReadLaneTests: XCTestCase { let sdk = PublicReadLaneSdk(noPointer: .init()) let service = PaykitSdkService(sdkFactory: { sdk }) let checks = (0 ..< 5).map { index in - Task { try await service.paymentRequestReceiverPaths(publicKey: "contact\(index)") } + Task { try await service.paymentRequestReceiverPaths(publicKey: "contact\(index)", priority: .bulk) } } try await assertBulkReadsLeaveTheLockAndReadSlotsFree(sdk: sdk, service: service) From 41b82ef14d4dfb059648933658f2724111f3e33f Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:27:57 +0200 Subject: [PATCH 25/79] fix: stay where the user went when a contact import finishes after they left it --- Bitkit/ViewModels/NavigationViewModel.swift | 5 +++++ Bitkit/Views/Contacts/ContactImportOverviewView.swift | 1 + Bitkit/Views/Contacts/ContactImportSelectView.swift | 1 + BitkitTests/ContactsManagerTests.swift | 7 +++++++ journeys/pubky-profile/README.md | 3 ++- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 9496a122a..7efd402c1 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -155,6 +155,11 @@ func shouldDiscardPendingImport(currentRoute: Route?, destination: Route?) -> Bo return destination?.isContactImportRoute != true } +/// A contact import outlives its screens, so when it finishes it opens Pay Contacts only if the user is still in the import flow. +func shouldOpenPayContactsAfterImport(currentRoute: Route?) -> Bool { + currentRoute?.isContactImportRoute == true +} + func fallbackRouteForMissingPendingImport(hasPendingImport: Bool) -> Route? { hasPendingImport ? nil : .payContacts } diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index 81eed8e67..01277dc51 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -173,6 +173,7 @@ struct ContactImportOverviewView: View { private func importAllContacts() async { do { try await contactsManager.importContacts(contacts) + guard shouldOpenPayContactsAfterImport(currentRoute: navigation.currentRoute) else { return } contactsManager.clearPendingImport() navigation.path = [.payContacts] } catch is CancellationError { diff --git a/Bitkit/Views/Contacts/ContactImportSelectView.swift b/Bitkit/Views/Contacts/ContactImportSelectView.swift index 9232bd947..995064656 100644 --- a/Bitkit/Views/Contacts/ContactImportSelectView.swift +++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift @@ -177,6 +177,7 @@ struct ContactImportSelectView: View { do { try await contactsManager.importContacts(selected) + guard shouldOpenPayContactsAfterImport(currentRoute: navigation.currentRoute) else { return } contactsManager.clearPendingImport() navigation.path = [.payContacts] } catch is CancellationError { diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index f51ef3a68..872fdff0f 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -662,6 +662,13 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(shouldDiscardPendingImport(currentRoute: .contacts, destination: .profile)) } + func testFinishedImportOpensPayContactsOnlyFromTheImportFlow() { + XCTAssertTrue(shouldOpenPayContactsAfterImport(currentRoute: .contactImportOverview)) + XCTAssertTrue(shouldOpenPayContactsAfterImport(currentRoute: .contactImportSelect)) + XCTAssertFalse(shouldOpenPayContactsAfterImport(currentRoute: .settings), "An import finishing after Back must not pull the user away") + XCTAssertFalse(shouldOpenPayContactsAfterImport(currentRoute: nil), "An import finishing on Home must not pull the user away") + } + func testDeleteAllContactsThrowsWithoutActiveSession() async { let manager = ContactsManager() manager.contacts = [ diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 271e10581..2a6bcf7d3 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -17,7 +17,8 @@ while profiles are still loading from the network. Where Android differs, see Import All. The import saves the profiles the overview already looked up instead of looking every follow up again, so it takes seconds rather than tens of seconds. A follow with no published profile is imported under its truncated key. While the import runs, Select and Import All are - disabled, and leaving the screen does not stop it. + disabled. Leaving the screen does not stop the import, and an import that finishes after you left + does not take you to Pay Contacts. - `contacts-list-loading.xml` relaunches and opens Contacts. The saved contacts show straight away under the names they were saved with, and each fills in its profile name and avatar as its lookup finishes. Reopening Contacts in the same session shows the profiles already found straight away. From bb82654c2e229f67279c8f63879a0b598aa72773 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:29:37 +0200 Subject: [PATCH 26/79] fix: keep loading contact profiles when a Contacts reload publishes nothing --- Bitkit/Managers/ContactsManager.swift | 72 ++++++++++++++++++-------- BitkitTests/ContactsManagerTests.swift | 58 +++++++++++++++++++++ 2 files changed, 108 insertions(+), 22 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index b27857752..643308c9f 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -119,7 +119,8 @@ class ContactsManager: ObservableObject { private var loadGeneration = 0 private var sessionGeneration = 0 private var isApplyingProfileRefresh = false - private var profileRefreshTask: Task? + private var profileRefresh: ContactProfileRefresh? + private var profileRefreshCount = 0 /// Profiles resolved this session for the owner's contacts, shown in place of stored labels while a load refreshes. private var resolvedProfiles: [String: PubkyProfile] = [:] private var resolvedProfilesOwner: String? @@ -174,10 +175,7 @@ class ContactsManager: ObservableObject { func reset() { loadGeneration += 1 sessionGeneration += 1 - profileRefreshTask?.cancel() - profileRefreshTask = nil - resolvedProfiles = [:] - resolvedProfilesOwner = nil + forgetResolvedProfiles(owner: nil) contacts = [] isLoading = false hasLoaded = false @@ -220,7 +218,7 @@ class ContactsManager: ObservableObject { /// Publishes the saved records straight away, each with the best profile already known, then looks the remaining /// profiles up in the background on the bulk read lane and updates rows as they resolve. A failed lookup leaves its - /// row as it is. A cancelled load publishes nothing. + /// row as it is. A cancelled or failed load publishes nothing and leaves a running refresh to finish. func loadContacts( for publicKey: String, fetchContactRecords: @escaping @Sendable () async throws -> [Paykit.ContactRecord], @@ -233,7 +231,6 @@ class ContactsManager: ObservableObject { loadGeneration += 1 let generation = loadGeneration - profileRefreshTask?.cancel() useResolvedProfiles(of: publicKey) isLoading = true loadErrorMessage = nil @@ -259,7 +256,6 @@ class ContactsManager: ObservableObject { hasLoaded = true refreshContactProfiles( for: records.filter { $0.profile == nil && overrides[Self.contactKey(for: $0)] == nil }, - generation: generation, fetchRemoteProfile: fetchRemoteProfile ) await PrivatePaykitService.shared @@ -305,37 +301,54 @@ class ContactsManager: ObservableObject { return PubkyContact(publicKey: publicKey, profile: PubkyProfile.forDisplay(publicKey: publicKey, name: label, imageUrl: nil)) } + /// Keeps a running refresh that already looks up every one of `records`, so reopening Contacts does not start the same + /// lookups again; otherwise replaces it. private func refreshContactProfiles( for records: [Paykit.ContactRecord], - generation: Int, fetchRemoteProfile: @escaping @Sendable (String) async throws -> PubkyProfile? ) { - let lookups = records.map { (publicKey: Self.contactKey(for: $0), label: $0.label) } - guard !lookups.isEmpty else { - profileRefreshTask = nil + let labels = Dictionary(records.map { (Self.contactKey(for: $0), $0.label) }, uniquingKeysWith: { first, _ in first }) + if let running = profileRefresh, Set(running.labels.keys).isSuperset(of: labels.keys) { return } + profileRefresh?.task.cancel() + profileRefresh = nil + guard !labels.isEmpty else { return } - profileRefreshTask = Task { [weak self] in + profileRefreshCount += 1 + let refreshID = profileRefreshCount + let task = Task { [weak self] in await withTaskGroup(of: (publicKey: String, profile: PubkyProfile?).self) { group in - for lookup in lookups { + for (publicKey, label) in labels { group.addTask { - let profile = try? await Self.resolveContactProfile(publicKey: lookup.publicKey, fetchRemoteProfile: fetchRemoteProfile) - return (lookup.publicKey, profile?.withNameFallback(lookup.label)) + let profile = try? await Self.resolveContactProfile(publicKey: publicKey, fetchRemoteProfile: fetchRemoteProfile) + return (publicKey, profile?.withNameFallback(label)) } } for await result in group { - guard let profile = result.profile else { continue } - self?.applyRefreshedProfile(profile, for: result.publicKey, generation: generation) + self?.finishRefreshLookup(of: result.publicKey, profile: result.profile, refreshID: refreshID) } } + self?.finishProfileRefresh(refreshID) + } + profileRefresh = ContactProfileRefresh(id: refreshID, labels: labels, task: task) + } + + private func finishRefreshLookup(of publicKey: String, profile: PubkyProfile?, refreshID: Int) { + guard profileRefresh?.id == refreshID else { return } + if let profile { + applyResolvedProfile(profile, for: publicKey) } } - /// Runs on the refresh task, so `Task.isCancelled` is that refresh's cancellation. - private func applyRefreshedProfile(_ profile: PubkyProfile, for publicKey: String, generation: Int) { - guard generation == loadGeneration, !Task.isCancelled else { return } + private func finishProfileRefresh(_ refreshID: Int) { + if profileRefresh?.id == refreshID { + profileRefresh = nil + } + } + + private func applyResolvedProfile(_ profile: PubkyProfile, for publicKey: String) { rememberResolvedProfile(profile, for: publicKey) guard Self.loadContactProfileOverrides()[publicKey] == nil, let index = contacts.firstIndex(where: { $0.publicKey == publicKey }) @@ -352,6 +365,14 @@ class ContactsManager: ObservableObject { private func useResolvedProfiles(of ownerPublicKey: String) { let owner = PubkyPublicKeyFormat.normalized(ownerPublicKey) ?? ownerPublicKey guard owner != resolvedProfilesOwner else { return } + forgetResolvedProfiles(owner: owner) + } + + /// Stops every profile lookup still running for the previous owner, so none of them can fill the next owner's rows + /// or cache. + private func forgetResolvedProfiles(owner: String?) { + profileRefresh?.task.cancel() + profileRefresh = nil resolvedProfiles = [:] resolvedProfilesOwner = owner } @@ -363,7 +384,7 @@ class ContactsManager: ObservableObject { #if DEBUG func waitForProfileRefreshForTesting() async { - await profileRefreshTask?.value + await profileRefresh?.task.value } #endif @@ -745,6 +766,13 @@ class ContactsManager: ObservableObject { } } + /// A background refresh of saved contacts' profiles. `labels` maps each contact it looks up to its saved label. + private struct ContactProfileRefresh { + let id: Int + let labels: [String: String?] + let task: Task + } + private enum FollowLookup { case resolved(PubkyContact) case unresolved(PubkyContact) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 872fdff0f..3c7132db1 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -543,6 +543,64 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(changes.last, [contactProfileKey, unresolvedFollowKey, addedKey]) } + func testReloadThatPublishesNothingLeavesTheRunningProfileRefreshToFinish() async throws { + let manager = ContactsManager() + let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + await lookups.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + while await lookups.heldCount < 1 { + await Task.yield() + } + + let fetchStarted = expectation(description: "Reload started reading the saved records") + let cancelledReload = Task { + try await manager.loadContacts( + for: "owner", + fetchContactRecords: { + fetchStarted.fulfill() + try await Task.sleep(nanoseconds: 60_000_000_000) + return records + }, + fetchRemoteProfile: { try await lookups.fetch($0) } + ) + } + await fulfillment(of: [fetchStarted], timeout: 2) + cancelledReload.cancel() + _ = await cancelledReload.result + do { + try await manager.loadContacts( + for: "owner", + fetchContactRecords: { throw PubkyServiceError.sessionNotActive }, + fetchRemoteProfile: { try await lookups.fetch($0) } + ) + XCTFail("Expected the failing reload to throw") + } catch {} + + await lookups.release() + await manager.waitForProfileRefreshForTesting() + XCTAssertEqual(manager.contacts.map(\.displayName), ["Alice"], "A reload that publishes nothing must not stop the refresh") + } + + func testReloadKeepsARunningProfileRefreshThatCoversItsContacts() async throws { + let manager = ContactsManager() + let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + await lookups.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + while await lookups.heldCount < 1 { + await Task.yield() + } + + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + await lookups.release() + await manager.waitForProfileRefreshForTesting() + + XCTAssertEqual(manager.contacts.map(\.displayName), ["Alice"]) + let fetchedKeys = await lookups.fetchedKeys + XCTAssertEqual(fetchedKeys, [contactProfileKey], "Reopening Contacts must not look the same contact up again") + } + func testImportSavesPreparedProfilesAndKeepsUnresolvedFollowsAsPlaceholders() async throws { let manager = ContactsManager() let placeholderName = Bitkit.PubkyProfile.placeholder(publicKey: unresolvedFollowKey).name From 9fe0fefbef7092a09f497ab9ddee15c26f66a8d5 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:29:46 +0200 Subject: [PATCH 27/79] fix: load a label-only contact's profile before editing it --- Bitkit/Managers/ContactsManager.swift | 53 ++++++++- Bitkit/Views/Contacts/ContactDetailView.swift | 81 +++++++------- Bitkit/Views/Contacts/EditContactView.swift | 89 ++++++++++----- BitkitTests/ContactsManagerTests.swift | 102 ++++++++++++++++++ journeys/pubky-profile/README.md | 6 +- .../pubky-profile/contacts-list-loading.xml | 9 +- 6 files changed, 267 insertions(+), 73 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 643308c9f..393903d35 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -121,9 +121,11 @@ class ContactsManager: ObservableObject { private var isApplyingProfileRefresh = false private var profileRefresh: ContactProfileRefresh? private var profileRefreshCount = 0 + private var pendingProfileLookups: [String: Task] = [:] /// Profiles resolved this session for the owner's contacts, shown in place of stored labels while a load refreshes. private var resolvedProfiles: [String: PubkyProfile] = [:] private var resolvedProfilesOwner: String? + private var resolvedProfilesGeneration = 0 private let contactRecords: @Sendable () async throws -> [ContactRecord] init(contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords) { @@ -332,11 +334,12 @@ class ContactsManager: ObservableObject { } self?.finishProfileRefresh(refreshID) } - profileRefresh = ContactProfileRefresh(id: refreshID, labels: labels, task: task) + profileRefresh = ContactProfileRefresh(id: refreshID, labels: labels, pendingKeys: Set(labels.keys), task: task) } private func finishRefreshLookup(of publicKey: String, profile: PubkyProfile?, refreshID: Int) { guard profileRefresh?.id == refreshID else { return } + profileRefresh?.pendingKeys.remove(publicKey) if let profile { applyResolvedProfile(profile, for: publicKey) } @@ -348,6 +351,47 @@ class ContactsManager: ObservableObject { } } + /// Looks a saved contact's profile up on the interactive read lane while its row still shows only its saved label + /// because the background refresh has not reached it, so a screen showing that contact does not wait behind bulk + /// reads and an edit made there keeps the contact's avatar, bio and links. Returns at once for any other row, and + /// joins a lookup already running for the contact. When the lookup fails, the row keeps its label. + func resolvePendingContactProfile(publicKey: String) async { + await resolvePendingContactProfile(publicKey: publicKey, fetchRemoteProfile: Self.fetchRemoteContactProfile) + } + + func resolvePendingContactProfile( + publicKey: String, + fetchRemoteProfile: @escaping @Sendable (String) async throws -> PubkyProfile? + ) async { + guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { return } + if let lookup = pendingProfileLookups[key] { + return await lookup.value + } + guard let refresh = profileRefresh, + refresh.pendingKeys.contains(key), + let label = refresh.labels[key], + resolvedProfiles[key] == nil, + Self.loadContactProfileOverrides()[key] == nil + else { return } + + let generation = resolvedProfilesGeneration + let lookup = Task { [weak self] in + let profile = try? await Self.resolveContactProfile(publicKey: key, fetchRemoteProfile: fetchRemoteProfile) + self?.finishPendingProfileLookup(of: key, profile: profile?.withNameFallback(label), generation: generation) + } + pendingProfileLookups[key] = lookup + await lookup.value + } + + private func finishPendingProfileLookup(of publicKey: String, profile: PubkyProfile?, generation: Int) { + guard generation == resolvedProfilesGeneration else { return } + pendingProfileLookups[publicKey] = nil + profileRefresh?.pendingKeys.remove(publicKey) + if let profile { + applyResolvedProfile(profile, for: publicKey) + } + } + private func applyResolvedProfile(_ profile: PubkyProfile, for publicKey: String) { rememberResolvedProfile(profile, for: publicKey) guard Self.loadContactProfileOverrides()[publicKey] == nil, @@ -371,8 +415,11 @@ class ContactsManager: ObservableObject { /// Stops every profile lookup still running for the previous owner, so none of them can fill the next owner's rows /// or cache. private func forgetResolvedProfiles(owner: String?) { + resolvedProfilesGeneration += 1 profileRefresh?.task.cancel() profileRefresh = nil + pendingProfileLookups.values.forEach { $0.cancel() } + pendingProfileLookups = [:] resolvedProfiles = [:] resolvedProfilesOwner = owner } @@ -766,10 +813,12 @@ class ContactsManager: ObservableObject { } } - /// A background refresh of saved contacts' profiles. `labels` maps each contact it looks up to its saved label. + /// A background refresh of saved contacts' profiles. `labels` maps each contact it looks up to its saved label, and + /// `pendingKeys` holds those whose lookup has not finished, whose rows may still show only that label. private struct ContactProfileRefresh { let id: Int let labels: [String: String?] + var pendingKeys: Set let task: Task } diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 99af0e0b3..3d9c728da 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -23,6 +23,7 @@ struct ContactDetailView: View { @State private var showDeleteConfirmation = false @State private var isPayLoading = false @State private var payTask: Task? + @State private var tagUpdate: Task? var body: some View { VStack(spacing: 0) { @@ -47,6 +48,7 @@ struct ContactDetailView: View { isLoading = false } isLoading = false + await contactsManager.resolvePendingContactProfile(publicKey: publicKey) } .task { if isPaymentRequestAvailable { @@ -226,50 +228,53 @@ struct ContactDetailView: View { // MARK: - Tag Persistence private func addTag(_ newTag: String) { - guard var current = profile else { return } - current = PubkyProfile( - publicKey: current.publicKey, - name: current.name, - bio: current.bio, - imageUrl: current.imageUrl, - links: current.links, - tags: current.tags + [newTag], - status: current.status - ) - profile = current - persistContact(current) + updateTags { $0 + [newTag] } } private func removeTag(_ tag: String) { - guard var current = profile else { return } - current = PubkyProfile( - publicKey: current.publicKey, - name: current.name, - bio: current.bio, - imageUrl: current.imageUrl, - links: current.links, - tags: current.tags.filter { $0 != tag }, - status: current.status + updateTags { $0.filter { $0 != tag } } + } + + /// Shows the change at once, then saves it, one tag change at a time, over the contact's latest profile. It first + /// waits for a profile the row is still waiting for, so a tag change never saves a label-only row over the contact's + /// bio, links and avatar. + private func updateTags(_ transform: @escaping ([String]) -> [String]) { + guard let current = profile else { return } + profile = Self.profile(current, withTags: transform(current.tags)) + let previousUpdate = tagUpdate + tagUpdate = Task { + await previousUpdate?.value + await contactsManager.resolvePendingContactProfile(publicKey: publicKey) + let latest = contactsManager.contacts.first(where: { $0.publicKey == publicKey })?.profile ?? current + await persistContact(Self.profile(latest, withTags: transform(latest.tags))) + } + } + + private static func profile(_ profile: PubkyProfile, withTags tags: [String]) -> PubkyProfile { + PubkyProfile( + publicKey: profile.publicKey, + name: profile.name, + bio: profile.bio, + imageUrl: profile.imageUrl, + links: profile.links, + tags: tags, + status: profile.status ) - profile = current - persistContact(current) } - private func persistContact(_ profile: PubkyProfile) { - Task { - do { - try await contactsManager.updateContact( - publicKey: publicKey, - name: profile.name, - bio: profile.bio, - imageUrl: profile.imageUrl, - links: profile.links, - tags: profile.tags - ) - } catch { - Logger.error("Failed to persist contact tags: \(error)", context: "ContactDetailView") - app.toast(type: .error, title: t("contacts__error_saving")) - } + private func persistContact(_ profile: PubkyProfile) async { + do { + try await contactsManager.updateContact( + publicKey: publicKey, + name: profile.name, + bio: profile.bio, + imageUrl: profile.imageUrl, + links: profile.links, + tags: profile.tags + ) + } catch { + Logger.error("Failed to persist contact tags: \(error)", context: "ContactDetailView") + app.toast(type: .error, title: t("contacts__error_saving")) } } diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 934d5f3f6..9aad8279a 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -9,11 +9,7 @@ struct EditContactView: View { let publicKey: String - @State private var name: String = "" - @State private var bio: String = "" - @State private var imageUrl: String? - @State private var links: [ProfileLinkInput] = [] - @State private var tags: [String] = [] + @State private var form = ContactEditForm() @State private var isSaving = false @State private var showDeleteConfirmation = false @State private var selectedPhotoItem: PhotosPickerItem? @@ -22,10 +18,10 @@ struct EditContactView: View { var body: some View { ProfileEditFormView( navigationTitle: t("contacts__edit_title"), - name: $name, - bio: $bio, - links: $links, - tags: $tags, + name: $form.name, + bio: $form.bio, + links: $form.links, + tags: $form.tags, publicKey: publicKey, publicKeyLabel: t("profile__create_pubky_label"), bioLabel: t("contacts__edit_notes_label"), @@ -45,15 +41,15 @@ struct EditContactView: View { .background(Color.customBlack) .navigationBarHidden(true) .task { - loadContactData() + await loadContactData() } - .alert(t("contacts__delete_title", variables: ["name": name]), isPresented: $showDeleteConfirmation) { + .alert(t("contacts__delete_title", variables: ["name": form.name]), isPresented: $showDeleteConfirmation) { Button(t("contacts__delete_confirm"), role: .destructive) { Task { await deleteContact() } } Button(t("common__dialog_cancel"), role: .cancel) {} } message: { - Text(t("contacts__delete_description", variables: ["name": name])) + Text(t("contacts__delete_description", variables: ["name": form.name])) } } @@ -68,7 +64,7 @@ struct EditContactView: View { .scaledToFill() .frame(width: 96, height: 96) .clipShape(Circle()) - } else if let imageUrl { + } else if let imageUrl = form.imageUrl { PubkyImage(uri: imageUrl, size: 96) } else { Circle() @@ -108,14 +104,16 @@ struct EditContactView: View { // MARK: - Data Loading - private func loadContactData() { + /// Fills the form from the contact's row at once, then again once a profile the row was still waiting for arrives. + private func loadContactData() async { + fillFormFromContact() + await contactsManager.resolvePendingContactProfile(publicKey: publicKey) + fillFormFromContact() + } + + private func fillFormFromContact() { guard let contact = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) else { return } - let profile = contact.profile - name = profile.name - bio = profile.bio - imageUrl = profile.imageUrl - links = profile.links.map { ProfileLinkInput(label: $0.label, url: $0.url) } - tags = profile.tags + form.fill(from: contact.profile) } // MARK: - Delete @@ -143,28 +141,30 @@ struct EditContactView: View { // MARK: - Save private func saveContact() async { - let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return } + guard !form.trimmedName.isEmpty else { return } isSaving = true defer { isSaving = false } + await contactsManager.resolvePendingContactProfile(publicKey: publicKey) + fillFormFromContact() + do { let uploadedImageUrl = if let avatarImage { try await pubkyProfile.uploadAvatar(image: avatarImage) } else { - imageUrl + form.imageUrl } try await contactsManager.updateContact( publicKey: publicKey, - name: trimmedName, - bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), + name: form.trimmedName, + bio: form.bio.trimmingCharacters(in: .whitespacesAndNewlines), imageUrl: uploadedImageUrl, - links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, - tags: tags + links: form.links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, + tags: form.tags ) - imageUrl = uploadedImageUrl + form.imageUrl = uploadedImageUrl app.toast( type: .success, title: t("contacts__edit_saved"), @@ -178,6 +178,39 @@ struct EditContactView: View { } } +/// The contact edit form's fields. Filling it again, such as when the contact's profile arrives after the form opened, +/// keeps every field the user has changed since the last fill. +struct ContactEditForm { + var name = "" + var bio = "" + var imageUrl: String? + var links: [ProfileLinkInput] = [] + var tags: [String] = [] + private var filledProfile: PubkyProfile? + + var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + mutating func fill(from profile: PubkyProfile) { + let filled = filledProfile + if filled.map({ name == $0.name }) ?? true { + name = profile.name + } + if filled.map({ bio == $0.bio }) ?? true { + bio = profile.bio + } + if filled.map({ links.map(\.label) == $0.links.map(\.label) && links.map(\.url) == $0.links.map(\.url) }) ?? true { + links = profile.links.map { ProfileLinkInput(label: $0.label, url: $0.url) } + } + if filled.map({ tags == $0.tags }) ?? true { + tags = profile.tags + } + imageUrl = profile.imageUrl + filledProfile = profile + } +} + #Preview { NavigationStack { EditContactView(publicKey: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 3c7132db1..045334ae1 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -601,6 +601,108 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(fetchedKeys, [contactProfileKey], "Reopening Contacts must not look the same contact up again") } + func testLabelOnlyContactLooksItsProfileUpOnTheInteractiveLane() async throws { + let manager = ContactsManager() + let bulk = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + let published = Bitkit.PubkyProfile( + publicKey: contactProfileKey, + name: "Alice", + bio: "Hello", + imageUrl: "pubky://alice/avatar", + links: [PubkyProfileLink(label: "Site", url: "https://alice.example")], + tags: [], + status: nil + ) + let interactive = ContactProfileFetchStub([.success(published)]) + + async let first: Void = manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + async let second: Void = manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + _ = await (first, second) + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + + let profile = try XCTUnwrap(manager.contacts.first?.profile) + XCTAssertEqual(profile.name, "Alice") + XCTAssertEqual(profile.bio, "Hello", "An edit made now must not save the label-only row over the bio") + XCTAssertEqual(profile.imageUrl, "pubky://alice/avatar") + XCTAssertEqual(profile.links.map(\.url), ["https://alice.example"]) + let attempts = await interactive.attempts + XCTAssertEqual(attempts, 1, "Concurrent callers share one lookup, and a resolved row is not looked up again") + await bulk.release() + await manager.waitForProfileRefreshForTesting() + } + + func testOnlyALabelOnlyContactIsLookedUpOnTheInteractiveLane() async throws { + let manager = ContactsManager() + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only"), contactRecord(key: unresolvedFollowKey, name: "Bob")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) + + await manager.resolvePendingContactProfile(publicKey: unresolvedFollowKey) { try await interactive.fetch($0) } + let storedAttempts = await interactive.attempts + XCTAssertEqual(storedAttempts, 0, "A stored profile is not looked up") + + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + let attempts = await interactive.attempts + XCTAssertEqual(attempts, 1, "A failed lookup is left to the background refresh") + XCTAssertEqual(manager.contacts.map(\.displayName), ["Bob", "Label only"]) + await bulk.release() + await manager.waitForProfileRefreshForTesting() + } + + func testInteractiveProfileLookupStartedBeforeResetIsIgnored() async throws { + let manager = ContactsManager() + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + let interactive = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + await interactive.hold() + let lookup = Task { + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + } + while await interactive.heldCount < 1 { + await Task.yield() + } + + manager.reset() + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + await interactive.release() + await lookup.value + await bulk.release() + await manager.waitForProfileRefreshForTesting() + + XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "A lookup from before the reset must not update rows") + } + + func testEditFormKeepsTheUsersChangesWhenTheContactsProfileArrives() { + var form = ContactEditForm() + form.fill(from: Bitkit.PubkyProfile.forDisplay(publicKey: contactProfileKey, name: "Label only", imageUrl: nil)) + form.name = "My Alice" + form.tags = ["friend"] + + form.fill(from: Bitkit.PubkyProfile( + publicKey: contactProfileKey, + name: "Alice", + bio: "Hello", + imageUrl: "pubky://alice/avatar", + links: [PubkyProfileLink(label: "Site", url: "https://alice.example")], + tags: ["remote"], + status: nil + )) + + XCTAssertEqual(form.name, "My Alice") + XCTAssertEqual(form.tags, ["friend"]) + XCTAssertEqual(form.bio, "Hello", "A field the user left alone takes the arriving profile") + XCTAssertEqual(form.imageUrl, "pubky://alice/avatar") + XCTAssertEqual(form.links.map(\.url), ["https://alice.example"]) + } + func testImportSavesPreparedProfilesAndKeepsUnresolvedFollowsAsPlaceholders() async throws { let manager = ContactsManager() let placeholderName = Bitkit.PubkyProfile.placeholder(publicKey: unresolvedFollowKey).name diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 2a6bcf7d3..490c78c27 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -22,6 +22,8 @@ while profiles are still loading from the network. Where Android differs, see - `contacts-list-loading.xml` relaunches and opens Contacts. The saved contacts show straight away under the names they were saved with, and each fills in its profile name and avatar as its lookup finishes. Reopening Contacts in the same session shows the profiles already found straight away. + Opening a contact whose profile has not loaded yet looks it up at once, and its edit form shows + the published bio. ## Preconditions @@ -51,8 +53,8 @@ pubky.app, at least one of them with no published profile, and no Pubky identity journey saves the follows as contacts; sign out in Bitkit before running it again. **Contacts list loading.** A Pubky identity with at least five saved contacts, at least one with a -published profile name and one with no published profile. Running the contact import journey first -leaves exactly that. +published profile name and bio and one with no published profile. Running the contact import journey +first leaves exactly that if one of the follows publishes a bio. ## Timing diff --git a/journeys/pubky-profile/contacts-list-loading.xml b/journeys/pubky-profile/contacts-list-loading.xml index 0ec8057b6..4023b3244 100644 --- a/journeys/pubky-profile/contacts-list-loading.xml +++ b/journeys/pubky-profile/contacts-list-loading.xml @@ -4,11 +4,13 @@ saved with, and fills in each contact's profile name and avatar as its lookup finishes. It no longer shows a spinner over an empty list until every lookup has finished, and a lookup that fails leaves the row as it is. Within the session, reopening Contacts shows the profiles already - found straight away. + found straight away. Opening a contact whose profile has not loaded yet looks it up at once, and + its edit form shows the contact's published bio rather than an empty one, so saving an edit keeps + it. Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Pubky identity with at - least five saved contacts, at least one of them with a published profile name and one with no - published profile. The contact import journey leaves exactly that. Use the test simulator UDID + least five saved contacts, at least one of them with a published profile name and bio and one + with no published profile. The contact import journey leaves exactly that. Use the test simulator UDID for <UDID>. A warm network can resolve every profile before the first snapshot; report "already resolved" @@ -20,6 +22,7 @@ If the PIN screen appears, unlock the wallet Tap the menu button (id "HeaderMenu"), then Contacts (id "DrawerContacts") Verify the list shows one row (id "Contact_<pubky>") per saved contact, each with a name, and no spinner over an empty list. A contact whose profile has not loaded yet shows the name it was saved with, or its truncated key + Tap the row of a contact with a published bio straight away and verify its contact screen shows that bio (id "ContactViewNotes") within a few seconds. Tap Edit (id "ContactEdit") and verify the notes field shows the same bio, then tap Cancel (id "ProfileEditCancel") and go back (id "NavigationBack") Wait up to a minute and verify each contact with a published profile now shows its profile name, and its avatar if it has one. If every row already did, report "already resolved" Verify a contact with no published profile still shows the name it had when the list opened Tap a contact's row and verify its contact screen opens with that contact's name, then go back From b8ad8b1a4e76908471e64579f909f2cb337d6de1 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:29:55 +0200 Subject: [PATCH 28/79] docs: list the contact import identifiers Android does not share --- journeys/README.md | 8 ++++++-- journeys/pubky-profile/README.md | 13 +++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index 91512aad6..8cf765956 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -127,6 +127,8 @@ Known naming differences: | Pubky Ring choice row | `PubkyChoiceIdentity` (the same tag on every row) | `PubkyChoiceRing_` (bare z32 key) | | Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` | `PubkyChoiceRingLookup_` (bare z32 key) | | Cached profile header while the profile loads | — | `ProfileCachedHeader`, with the name `ProfileCachedName` | +| Contact import overview profile and friend count | — | `ContactImportOverviewProfile`, `ContactImportOverviewSummary` | +| Contact import selection row | — | `ContactImportSelect_` | `SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix but appends the billing period (`-one-time` for a one-off), because every recurring payment of one @@ -188,8 +190,10 @@ iOS-only — a Bitkit-specific OS handoff into watch-only consent, with no Andro `profile/signup-create-profile.xml` is currently iOS-only. It covers the iOS Profile Error regression after staging signup. Android also supports this signup flow, but this journey has not been ported there. -`pubky-profile/contact-import.xml` and `pubky-profile/contacts-list-loading.xml` started on iOS. They -use identifiers Android already shares, so they can be ported under the same names. +`pubky-profile/contact-import.xml` is new on both platforms at once: synonymdev/bitkit-android#1399 +carries an Android journey with the same name but its own steps, and adds the import button testTags +it shares with iOS. `pubky-profile/contacts-list-loading.xml` is iOS-only for now. The identifiers +the two journeys use that Android does not have are in the Identifiers table above. A journey is a shared spec, so a behaviour that is meant to match Android can be checked by running the same file on both sides: `xcodebuildmcp` here, the `android` CLI against a `bitkit-android` diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 490c78c27..97abf4a0a 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -76,10 +76,15 @@ header showed. - **Profile while loading.** iOS shows the cached name and avatar read-only under `ProfileCachedHeader`, with no edit, copy, share, QR code or tag controls, and then the full profile under `ProfileViewName`. -- **Contact import and Contacts list.** These two journeys are new on iOS. The import overview, - selection and Contacts identifiers already match Android (`ContactImportOverviewSelect`, - `ContactImportOverviewImportAll`, `ContactImportSelectContinue`, `Contact_`). iOS has always - kept a follow with no published profile in the import; it shows under its truncated key. +- **Contact import and Contacts list.** `contact-import.xml` is new on both platforms at once: + synonymdev/bitkit-android#1399 adds an Android journey with the same file and journey name but its + own steps, and adds the testTags `ContactImportOverviewSelect`, `ContactImportOverviewImportAll` and + `ContactImportSelectContinue`. `Contact_`, `HeaderMenu` and `DrawerContacts` already match + Android. The overview profile and summary (`ContactImportOverviewProfile`, + `ContactImportOverviewSummary`) and the selection rows (`ContactImportSelect_`) are iOS-only; + see [Identifiers](../README.md#identifiers). `contacts-list-loading.xml` is iOS-only for now; the + Android journey checks the list at the end of its import instead. iOS has always kept a follow with + no published profile in the import; it shows under its truncated key. - **Ring choice rows.** Android names the rows and their lookup spinners differently; see [Identifiers](../README.md#identifiers). iOS keeps the rows up, spins only the tapped row, and tags each row `PubkyChoiceRing_`, using the bare z32 key without the `pubky` prefix. From 4f864782f41d0c9e888f45d662e1c1959c1bbd23 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:32:29 +0200 Subject: [PATCH 29/79] refactor: share the contact name order and derive the import flag from its count --- Bitkit/Managers/ContactsManager.swift | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 393903d35..800c3da85 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -152,8 +152,11 @@ class ContactsManager: ObservableObject { @Published var isLoading = false @Published var hasLoaded = false /// An import runs on its own task and outlives the import screens, which read this to keep a second import from starting. - @Published private(set) var isImportingContacts = false - private var activeImportCount = 0 + var isImportingContacts: Bool { + activeImportCount > 0 + } + + @Published private var activeImportCount = 0 @Published var loadErrorMessage: String? @Published var shouldOpenAddContactSheet = false @@ -253,8 +256,7 @@ class ContactsManager: ObservableObject { Logger.debug("Loaded \(records.count) SDK contact records", context: "ContactsManager") let overrides = Self.loadContactProfileOverrides() - contacts = records.map { savedContact(from: $0, overrides: overrides) } - .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + contacts = records.map { savedContact(from: $0, overrides: overrides) }.sorted(by: Self.isOrderedByName) hasLoaded = true refreshContactProfiles( for: records.filter { $0.profile == nil && overrides[Self.contactKey(for: $0)] == nil }, @@ -400,7 +402,7 @@ class ContactsManager: ObservableObject { var refreshed = contacts refreshed[index] = PubkyContact(publicKey: publicKey, profile: profile) - refreshed.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + refreshed.sort(by: Self.isOrderedByName) isApplyingProfileRefresh = true contacts = refreshed isApplyingProfileRefresh = false @@ -477,7 +479,7 @@ class ContactsManager: ObservableObject { rememberResolvedProfile(profile, for: prefixedKey) let contact = PubkyContact(publicKey: prefixedKey, profile: profile) contacts.append(contact) - contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + contacts.sort(by: Self.isOrderedByName) } func refreshContactReceiverPaths(publicKey: String, wallet: WalletViewModel) async { @@ -534,11 +536,7 @@ class ContactsManager: ObservableObject { let session = sessionGeneration let unresolvedKeys = pendingImportUnresolvedKeys activeImportCount += 1 - isImportingContacts = true - defer { - activeImportCount -= 1 - isImportingContacts = activeImportCount > 0 - } + defer { activeImportCount -= 1 } var seenKeys = Set() let imports: [(contact: PubkyContact, isResolved: Bool)] = contactsToImport.compactMap { contact in guard let key = PubkyPublicKeyFormat.normalized(contact.publicKey), seenKeys.insert(key).inserted else { return nil } @@ -598,7 +596,7 @@ class ContactsManager: ObservableObject { let existingKeys = Set(contacts.map(\.publicKey)) let newContacts = loadedResult.contacts.filter { !existingKeys.contains($0.publicKey) } contacts.append(contentsOf: newContacts) - contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + contacts.sort(by: Self.isOrderedByName) if loadedResult.failures > 0 { Logger.warn("Skipped \(loadedResult.failures) contacts during import", context: "ContactsManager") @@ -632,7 +630,7 @@ class ContactsManager: ObservableObject { let updatedProfile = contactData.toProfile(publicKey: prefixedKey) if let index = contacts.firstIndex(where: { $0.publicKey == prefixedKey }) { contacts[index] = PubkyContact(publicKey: prefixedKey, profile: updatedProfile) - contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + contacts.sort(by: Self.isOrderedByName) } Logger.info("Updated contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") @@ -802,9 +800,7 @@ class ContactsManager: ObservableObject { Logger.warn("Skipped \(failures) remote contacts during discovery", context: "ContactsManager") } - pendingImportContacts = discovered.sorted { - $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending - } + pendingImportContacts = discovered.sorted(by: Self.isOrderedByName) pendingImportUnresolvedKeys = unresolvedKeys } catch { Logger.warn("Failed to discover remote contacts: \(error)", context: "ContactsManager") @@ -953,6 +949,10 @@ class ContactsManager: ObservableObject { private nonisolated static let contactProfileOverridesKey = "pubkyContactProfileOverrides" + private nonisolated static func isOrderedByName(_ lhs: PubkyContact, _ rhs: PubkyContact) -> Bool { + lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + private nonisolated static func contactKey(for record: Paykit.ContactRecord) -> String { PubkyPublicKeyFormat.normalized(record.publicKey) ?? ensurePubkyPrefix(record.publicKey) } From e4047a25a739da1f640264eaba031bf64f910cf3 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:32:30 +0200 Subject: [PATCH 30/79] refactor: copy a profile with new tags through PubkyProfile --- Bitkit/Models/PubkyProfile.swift | 4 ++++ Bitkit/Views/Contacts/ContactDetailView.swift | 16 ++-------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index aa263e44d..fb4cd5623 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -179,6 +179,10 @@ struct PubkyProfile { ) } + func withTags(_ tags: [String]) -> PubkyProfile { + PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) + } + func withNameFallback(_ fallbackName: String?) -> PubkyProfile { guard name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let fallbackName, diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 3d9c728da..8637d2a88 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -240,28 +240,16 @@ struct ContactDetailView: View { /// bio, links and avatar. private func updateTags(_ transform: @escaping ([String]) -> [String]) { guard let current = profile else { return } - profile = Self.profile(current, withTags: transform(current.tags)) + profile = current.withTags(transform(current.tags)) let previousUpdate = tagUpdate tagUpdate = Task { await previousUpdate?.value await contactsManager.resolvePendingContactProfile(publicKey: publicKey) let latest = contactsManager.contacts.first(where: { $0.publicKey == publicKey })?.profile ?? current - await persistContact(Self.profile(latest, withTags: transform(latest.tags))) + await persistContact(latest.withTags(transform(latest.tags))) } } - private static func profile(_ profile: PubkyProfile, withTags tags: [String]) -> PubkyProfile { - PubkyProfile( - publicKey: profile.publicKey, - name: profile.name, - bio: profile.bio, - imageUrl: profile.imageUrl, - links: profile.links, - tags: tags, - status: profile.status - ) - } - private func persistContact(_ profile: PubkyProfile) async { do { try await contactsManager.updateContact( From 78602a30d19bb3afe801c23c8c9c735e10e14a6b Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:52:48 +0200 Subject: [PATCH 31/79] fix: look follows up on the interactive read lane while preparing an import --- Bitkit/Managers/ContactsManager.swift | 35 +++++++++++++++++++------- Bitkit/Services/PubkyService.swift | 3 ++- BitkitTests/ContactsManagerTests.swift | 32 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 800c3da85..51368871c 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -127,9 +127,19 @@ class ContactsManager: ObservableObject { private var resolvedProfilesOwner: String? private var resolvedProfilesGeneration = 0 private let contactRecords: @Sendable () async throws -> [ContactRecord] - - init(contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords) { + private let fetchFollows: @Sendable (String) async throws -> [String] + private let fetchRemoteProfile: @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? + + init( + contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords, + fetchFollows: @escaping @Sendable (String) async throws -> [String] = { try await PubkyService.getContacts(publicKey: $0) }, + fetchRemoteProfile: @escaping @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? = { + try await ContactsManager.remoteContactProfile(publicKey: $0, priority: $1) + } + ) { self.contactRecords = contactRecords + self.fetchFollows = fetchFollows + self.fetchRemoteProfile = fetchRemoteProfile } /// Profile refreshes replace rows without counting as a change to the saved contacts. @@ -217,7 +227,7 @@ class ContactsManager: ObservableObject { try await loadContacts( for: publicKey, fetchContactRecords: contactRecords, - fetchRemoteProfile: Self.fetchRemoteContactProfileInBulk + fetchRemoteProfile: remoteProfileLookup(on: .bulk) ) } @@ -358,7 +368,7 @@ class ContactsManager: ObservableObject { /// reads and an edit made there keeps the contact's avatar, bio and links. Returns at once for any other row, and /// joins a lookup already running for the contact. When the lookup fails, the row keeps its label. func resolvePendingContactProfile(publicKey: String) async { - await resolvePendingContactProfile(publicKey: publicKey, fetchRemoteProfile: Self.fetchRemoteContactProfile) + await resolvePendingContactProfile(publicKey: publicKey, fetchRemoteProfile: remoteProfileLookup(on: .interactive)) } func resolvePendingContactProfile( @@ -717,6 +727,8 @@ class ContactsManager: ObservableObject { // MARK: - Remote Contact Discovery + /// Looks every follow's profile up on the interactive read lane: the user waits on the choice screen until the import + /// overview opens, and the bulk lane would use only four of the six read slots for those lookups. @discardableResult func prepareImport(profile: PubkyProfile?, publicKey: String) async -> Bool { clearPendingImport() @@ -739,8 +751,8 @@ class ContactsManager: ObservableObject { func discoverRemoteContacts(publicKey: String) async { await discoverRemoteContacts( publicKey: publicKey, - fetchFollows: { try await PubkyService.getContacts(publicKey: $0) }, - fetchRemoteProfile: Self.fetchRemoteContactProfileInBulk + fetchFollows: fetchFollows, + fetchRemoteProfile: remoteProfileLookup(on: .interactive) ) } @@ -864,14 +876,19 @@ class ContactsManager: ObservableObject { } nonisolated static let fetchRemoteContactProfile: @Sendable (String) async throws -> PubkyProfile? = { - try await PubkyService.resolveContactProfile(publicKey: $0, allowPubkyProfileFallback: true).map(PubkyProfile.init(resolution:)) + try await remoteContactProfile(publicKey: $0, priority: .interactive) } - nonisolated static let fetchRemoteContactProfileInBulk: @Sendable (String) async throws -> PubkyProfile? = { - try await PubkyService.resolveContactProfile(publicKey: $0, allowPubkyProfileFallback: true, priority: .bulk) + nonisolated static func remoteContactProfile(publicKey: String, priority: PaykitPublicReadPriority) async throws -> PubkyProfile? { + try await PubkyService.resolveContactProfile(publicKey: publicKey, allowPubkyProfileFallback: true, priority: priority) .map(PubkyProfile.init(resolution:)) } + private func remoteProfileLookup(on priority: PaykitPublicReadPriority) -> @Sendable (String) async throws -> PubkyProfile? { + let fetchRemoteProfile = fetchRemoteProfile + return { try await fetchRemoteProfile($0, priority) } + } + /// A missing profile is never retried. `retryTransient` retries any other failure once and is for user-initiated /// lookups only: the SDK reports a key with no pkarr record and a network failure as the same transport error, so a /// retry in bulk loads doubles the wait for every contact without a profile. diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 923e2b5bf..b7693f0f4 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -47,7 +47,8 @@ struct PubkyRegisteredIdentity { } /// Which public read slots a read may use. Background work that reads for many contacts at once is `bulk`, so it can -/// never take every read slot from reads for what the user is looking at. +/// never take every read slot from reads for what the user is looking at. Reads the user is waiting on are +/// `interactive` even when there are many of them, such as the follow lookups that prepare a contact import. enum PaykitPublicReadPriority { case interactive case bulk diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 045334ae1..2bd1c7f5f 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -733,6 +733,27 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(Set(manager.contacts.map(\.displayName)), ["Alice", placeholderName]) } + func testPreparingAnImportLooksFollowsUpOnTheInteractiveLane() async throws { + let lanes = ProfileLookupLanes() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + let manager = ContactsManager( + contactRecords: { records }, + fetchFollows: { _ in [contactProfileKey, unresolvedFollowKey] }, + fetchRemoteProfile: { try await lanes.fetch($0, priority: $1) } + ) + + let hasImportData = await manager.prepareImport(profile: nil, publicKey: "owner") + + XCTAssertTrue(hasImportData) + let importLanes = await lanes.priorities + XCTAssertEqual(importLanes, [.interactive, .interactive], "The user waits on the choice screen while the import is prepared") + + try await manager.loadContacts(for: "owner") + await manager.waitForProfileRefreshForTesting() + let allLanes = await lanes.priorities + XCTAssertEqual(Array(allLanes.dropFirst(importLanes.count)), [.bulk], "The Contacts list still refreshes its profiles in the background") + } + func testImportSkipsOnlyContactsWhoseSaveFails() async throws { let alice = makeContact(publicKey: contactProfileKey) let other = makeContact(publicKey: unresolvedFollowKey) @@ -988,6 +1009,17 @@ private actor HeldProfileLookups { } } +/// Records the read lane of each profile lookup, answering for `contactProfileKey` and failing for any other key. +private actor ProfileLookupLanes { + private(set) var priorities: [PaykitPublicReadPriority] = [] + + func fetch(_ publicKey: String, priority: PaykitPublicReadPriority) throws -> Bitkit.PubkyProfile? { + priorities.append(priority) + guard publicKey == contactProfileKey else { throw profileTransportError } + return Bitkit.PubkyProfile(publicKey: publicKey, name: "Alice", bio: "", imageUrl: nil, links: [], status: nil) + } +} + /// Records an import's receiver discovery and saves, and can fail saves or hold discovery until released. private actor ImportStub { private(set) var discoveredKeys: [String] = [] From a2fee4d8b113b12bfff3f7480ad03b6c3881b0c6 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:55:07 +0200 Subject: [PATCH 32/79] docs: describe the contact import journeys as shared with Android --- journeys/README.md | 11 +++++++---- journeys/pubky-profile/README.md | 21 ++++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index 8cf765956..c19316b60 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -190,10 +190,13 @@ iOS-only — a Bitkit-specific OS handoff into watch-only consent, with no Andro `profile/signup-create-profile.xml` is currently iOS-only. It covers the iOS Profile Error regression after staging signup. Android also supports this signup flow, but this journey has not been ported there. -`pubky-profile/contact-import.xml` is new on both platforms at once: synonymdev/bitkit-android#1399 -carries an Android journey with the same name but its own steps, and adds the import button testTags -it shares with iOS. `pubky-profile/contacts-list-loading.xml` is iOS-only for now. The identifiers -the two journeys use that Android does not have are in the Identifiers table above. +`pubky-profile/contact-import.xml` and `pubky-profile/contacts-list-loading.xml` are new on both +platforms at once. synonymdev/bitkit-android#1399 carries both journeys with the same file names, +journey names and prose, changing only the identifiers and commands the platform forces, and adds the +import testTags `ContactImportOverviewSelect`, `ContactImportOverviewImportAll` and +`ContactImportSelectContinue`; once it merges, the two platforms share the journeys and those +identifiers. Android master has neither until then. The identifiers the journeys use that still +differ on Android are in the Identifiers table above. A journey is a shared spec, so a behaviour that is meant to match Android can be checked by running the same file on both sides: `xcodebuildmcp` here, the `android` CLI against a `bitkit-android` diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 97abf4a0a..d2c803c00 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -76,15 +76,18 @@ header showed. - **Profile while loading.** iOS shows the cached name and avatar read-only under `ProfileCachedHeader`, with no edit, copy, share, QR code or tag controls, and then the full profile under `ProfileViewName`. -- **Contact import and Contacts list.** `contact-import.xml` is new on both platforms at once: - synonymdev/bitkit-android#1399 adds an Android journey with the same file and journey name but its - own steps, and adds the testTags `ContactImportOverviewSelect`, `ContactImportOverviewImportAll` and - `ContactImportSelectContinue`. `Contact_`, `HeaderMenu` and `DrawerContacts` already match - Android. The overview profile and summary (`ContactImportOverviewProfile`, - `ContactImportOverviewSummary`) and the selection rows (`ContactImportSelect_`) are iOS-only; - see [Identifiers](../README.md#identifiers). `contacts-list-loading.xml` is iOS-only for now; the - Android journey checks the list at the end of its import instead. iOS has always kept a follow with - no published profile in the import; it shows under its truncated key. +- **Contact import and Contacts list.** `contact-import.xml` and `contacts-list-loading.xml` are new + on both platforms at once: synonymdev/bitkit-android#1399 adds both to Android with the same file + names, journey names and prose, changing only identifiers and `adb` commands, and adds the + testTags `ContactImportOverviewSelect`, `ContactImportOverviewImportAll` and + `ContactImportSelectContinue`, so once it merges the two platforms share the journeys and those + identifiers. Android master has neither until then. `Contact_`, `HeaderMenu`, + `DrawerContacts`, `PayContactsContinue`, `ContactViewNotes`, `ContactEdit`, `ProfileEditCancel` + and `NavigationBack` already match Android. Android has no identifier for the overview profile and + summary (`ContactImportOverviewProfile`, `ContactImportOverviewSummary`) or the selection rows + (`ContactImportSelect_`), and names the Ring rows differently; see + [Identifiers](../README.md#identifiers). iOS has always kept a follow with no published profile in + the import; it shows under its truncated key. - **Ring choice rows.** Android names the rows and their lookup spinners differently; see [Identifiers](../README.md#identifiers). iOS keeps the rows up, spins only the tapped row, and tags each row `PubkyChoiceRing_`, using the bare z32 key without the `pubky` prefix. From e6c9c7a8ad8d4f5b03ea70c098977238c45d3d4b Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:55:20 +0200 Subject: [PATCH 33/79] docs: drop the cached profile header from the Android naming differences --- journeys/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/journeys/README.md b/journeys/README.md index c19316b60..fa332daf1 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -126,7 +126,6 @@ Known naming differences: | Payment Request details screen | `PaymentRequestDetailsScreen` | `PaymentRequestDetailScreen` | | Pubky Ring choice row | `PubkyChoiceIdentity` (the same tag on every row) | `PubkyChoiceRing_` (bare z32 key) | | Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` | `PubkyChoiceRingLookup_` (bare z32 key) | -| Cached profile header while the profile loads | — | `ProfileCachedHeader`, with the name `ProfileCachedName` | | Contact import overview profile and friend count | — | `ContactImportOverviewProfile`, `ContactImportOverviewSummary` | | Contact import selection row | — | `ContactImportSelect_` | From d3815400acfdbf50c69dc79c07215f9dd6ec88d5 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 17:59:15 +0200 Subject: [PATCH 34/79] docs: note how Android journeys find the Ring rows and import overview --- journeys/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index fa332daf1..54c742504 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -124,9 +124,9 @@ Known naming differences: | Send max | `SendAmountMax` | *(no button — tap `AvailableAmount`)* | | External amount available | — | `ExternalAmountAvailable` | | Payment Request details screen | `PaymentRequestDetailsScreen` | `PaymentRequestDetailScreen` | -| Pubky Ring choice row | `PubkyChoiceIdentity` (the same tag on every row) | `PubkyChoiceRing_` (bare z32 key) | -| Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` | `PubkyChoiceRingLookup_` (bare z32 key) | -| Contact import overview profile and friend count | — | `ContactImportOverviewProfile`, `ContactImportOverviewSummary` | +| Pubky Ring choice row | `PubkyChoiceIdentity` (the same tag on every row; tell rows apart by their key caption) | `PubkyChoiceRing_` (bare z32 key) | +| Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` (the same tag on every row) | `PubkyChoiceRingLookup_` (bare z32 key) | +| Contact import overview profile and friend count | — *(no tag; read the "Import" title and the "N friends" text)* | `ContactImportOverviewProfile`, `ContactImportOverviewSummary` | | Contact import selection row | — | `ContactImportSelect_` | `SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix From 4e5e7ef0f56530615547645ecf44f4e38796c08c Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 18:46:10 +0200 Subject: [PATCH 35/79] fix: remember the profiles a contact import saves for the Contacts list The import saves only each contact's label, so reopening Contacts showed imported contacts under their labels, without avatars, until the background refresh looked them up again. The import now remembers the profiles it saved for this session, as adding a contact does. A placeholder for a follow whose lookup failed is not remembered, so its profile is still looked up before an edit, and an import that finishes after a reset or another identity's load remembers nothing. --- Bitkit/Managers/ContactsManager.swift | 8 ++++ Bitkit/Models/PubkyProfile.swift | 5 ++ BitkitTests/ContactsManagerTests.swift | 63 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 825e77dce..ed17d49dc 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -509,12 +509,15 @@ class ContactsManager: ObservableObject { // MARK: - Import Contacts + /// Remembers the profiles it saves for this session, so the Contacts list shows them at once rather than only their + /// saved labels. A placeholder for a follow whose lookup failed is not remembered, so that profile is still looked up. func importContacts( contacts selected: [PubkyContact], saveContact: (String, String) async throws -> Void = { publicKey, label in _ = try await PubkyService.saveContact(publicKey: publicKey, label: label, restorePrivateConnection: true) } ) async throws { + let profilesGeneration = resolvedProfilesGeneration var imported: [PubkyContact] = [] var existingKeys = Set(contacts.map(\.publicKey)) var firstError: Error? @@ -539,6 +542,11 @@ class ContactsManager: ObservableObject { } try Task.checkCancellation() + if profilesGeneration == resolvedProfilesGeneration { + for contact in imported where !contact.profile.isPlaceholder { + rememberResolvedProfile(contact.profile, for: contact.publicKey) + } + } let currentKeys = Set(contacts.map(\.publicKey)) contacts.append(contentsOf: imported.filter { !currentKeys.contains($0.publicKey) }) contacts.sort(by: Self.isOrderedByName) diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index fb4cd5623..a1b6b41d7 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -168,6 +168,11 @@ struct PubkyProfile { ) } + /// Holds nothing beyond what `placeholder(publicKey:)` makes from the key, as for a contact whose lookup failed. + var isPlaceholder: Bool { + name == Self.truncate(publicKey) && bio.isEmpty && imageUrl == nil && links.isEmpty && tags.isEmpty && status == nil + } + static func forDisplay(publicKey: String, name: String?, imageUrl: String?) -> PubkyProfile { PubkyProfile( publicKey: publicKey, diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 88682a4a2..12eca31ef 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -857,6 +857,69 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(Array(allLanes.dropFirst(importLanes.count)), [.bulk], "The Contacts list still refreshes its profiles in the background") } + func testContactsReloadShowsImportedProfilesAtOnceButStillLooksPlaceholdersUp() async throws { + let published = Bitkit.PubkyProfile( + publicKey: contactProfileKey, name: "Alice", bio: "Hello", imageUrl: "pubky://alice/avatar", links: [], status: nil + ) + let manager = ContactsManager( + fetchFollows: { _ in [contactProfileKey, unresolvedFollowKey] }, + fetchRemoteProfile: { publicKey, _ in + guard publicKey == contactProfileKey else { throw profileTransportError } + return published + } + ) + await manager.prepareImport(profile: nil, publicKey: "owner") + let placeholderName = Bitkit.PubkyProfile.placeholder(publicKey: unresolvedFollowKey).name + XCTAssertEqual(Set(manager.pendingImportContacts.map(\.displayName)), ["Alice", placeholderName]) + try await manager.importContacts(contacts: manager.pendingImportContacts) { _, _ in } + + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Alice"), unprofiledRecord(key: unresolvedFollowKey, label: placeholderName)] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + + let alice = try XCTUnwrap(manager.contacts.first { $0.publicKey == contactProfileKey }) + XCTAssertEqual(alice.profile.imageUrl, "pubky://alice/avatar", "An imported profile shows before the background refresh reaches it") + XCTAssertEqual(alice.profile.bio, "Hello") + let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) + await manager.resolvePendingContactProfile(publicKey: unresolvedFollowKey) { try await interactive.fetch($0) } + let attempts = await interactive.attempts + XCTAssertEqual(attempts, 1, "A follow imported as a placeholder must still have its profile looked up before an edit") + await bulk.release() + await manager.waitForProfileRefreshForTesting() + } + + func testImportFinishingAfterResetDoesNotRememberItsProfiles() async throws { + let published = Bitkit.PubkyProfile( + publicKey: contactProfileKey, name: "Alice", bio: "Hello", imageUrl: "pubky://alice/avatar", links: [], status: nil + ) + let manager = ContactsManager() + try await manager.loadContacts(for: "owner", fetchContactRecords: { [] }, fetchRemoteProfile: { _ in nil }) + let saves = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + await saves.hold() + let importTask = Task { + try await manager.importContacts(contacts: [Bitkit.PubkyContact(publicKey: contactProfileKey, profile: published)]) { key, _ in + _ = try await saves.fetch(key) + } + } + while await saves.heldCount < 1 { + await Task.yield() + } + + manager.reset() + try await manager.loadContacts(for: "owner", fetchContactRecords: { [] }, fetchRemoteProfile: { _ in nil }) + await saves.release() + try await importTask.value + + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Alice")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + XCTAssertNil(manager.contacts.first?.profile.imageUrl, "An import from before the reset must not seed the next session's profiles") + await bulk.release() + await manager.waitForProfileRefreshForTesting() + } + func testShouldDiscardPendingImportWhenLeavingImportFlow() { XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .contacts)) XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportSelect, destination: nil)) From 16611f7e703d3730274c2e7e539946a9fe2097a9 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 18:50:13 +0200 Subject: [PATCH 36/79] fix: stay where the user went when a contact import finishes after they left it An import runs on its own task, so it keeps saving after the user leaves the import screens, and when it finished it opened Pay Contacts from wherever the user had gone. Leaving the import flow already discards the pending import, so a finished import now opens Pay Contacts only while its import is still pending. --- Bitkit/Managers/ContactsManager.swift | 9 +++++++++ .../Views/Contacts/ContactImportOverviewView.swift | 5 +++-- .../Views/Contacts/ContactImportSelectView.swift | 5 +++-- BitkitTests/ContactsManagerTests.swift | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index ed17d49dc..84308f55b 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -194,6 +194,15 @@ class ContactsManager: ObservableObject { pendingImportContacts = [] } + /// Clears the pending import once an import of it finishes and returns whether it was still pending. An import + /// outlives its screens and leaving the import flow discards the pending import, so false means the user left and + /// must not be taken to Pay Contacts. + func completePendingImport() -> Bool { + guard hasPendingImport else { return false } + clearPendingImport() + return true + } + // MARK: - Load Contacts func loadContactsIfNeeded(for publicKey: String) async throws { diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index 6ce82b604..809650c5e 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -176,8 +176,9 @@ struct ContactImportOverviewView: View { do { try await contactsManager.importContacts(contacts: contacts) - contactsManager.clearPendingImport() - navigation.path = [.payContacts] + if contactsManager.completePendingImport() { + navigation.path = [.payContacts] + } } catch { app.toast(type: .error, title: t("contacts__import_error")) } diff --git a/Bitkit/Views/Contacts/ContactImportSelectView.swift b/Bitkit/Views/Contacts/ContactImportSelectView.swift index de3b3434d..d8832e95d 100644 --- a/Bitkit/Views/Contacts/ContactImportSelectView.swift +++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift @@ -182,8 +182,9 @@ struct ContactImportSelectView: View { do { try await contactsManager.importContacts(contacts: selected) - contactsManager.clearPendingImport() - navigation.path = [.payContacts] + if contactsManager.completePendingImport() { + navigation.path = [.payContacts] + } } catch { app.toast(type: .error, title: t("contacts__import_error")) } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 12eca31ef..c89d90e2c 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -930,6 +930,20 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(shouldDiscardPendingImport(currentRoute: .contacts, destination: .profile)) } + func testFinishedImportOpensPayContactsOnlyWhileItsImportIsStillPending() { + let manager = ContactsManager() + manager.pendingImportProfile = makeProfile(publicKey: "pubky-owner") + manager.pendingImportContacts = [makeContact(publicKey: contactProfileKey)] + XCTAssertTrue(manager.completePendingImport(), "An import that finishes on the import screens opens Pay Contacts") + XCTAssertFalse(manager.hasPendingImport) + + manager.pendingImportProfile = makeProfile(publicKey: "pubky-owner") + manager.pendingImportContacts = [makeContact(publicKey: contactProfileKey)] + XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .settings)) + manager.clearPendingImport() + XCTAssertFalse(manager.completePendingImport(), "An import that finishes after the user left must not pull them away") + } + func testDeleteAllContactsThrowsWithoutActiveSession() async { let manager = ContactsManager() manager.contacts = [ From 99b9ecba41dcd321e91edef9a8c1037a41b4252c Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 18:57:53 +0200 Subject: [PATCH 37/79] fix: reject public pubky reads that race a wallet wipe Public reads and the private receiver path selection skip the SDK lock, and with it the wallet wipe admission check every locked call makes. A read that started while a wipe was waiting for the lock or running its cleanup used the wiped wallet's SDK, and a read that a wipe overtook returned its result afterwards, where callers such as the avatar cache kept it. Unlocked work now goes through the same admission: it is rejected while a wipe is in progress, except for the wipe's own work, and fails with the wipe error when a wipe started while it ran. An SDK is still built only under the lock, so a read after the wipe builds a fresh one from the cleared state. --- Bitkit/Services/PubkyService.swift | 107 +++++++----- BitkitTests/PaykitPublicReadLaneTests.swift | 164 ++++++++++++++++++ BitkitTests/PaykitSdkOperationLockTests.swift | 76 ++++++++ 3 files changed, 302 insertions(+), 45 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index b7693f0f4..fef97e77f 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -827,49 +827,51 @@ actor PaykitSdkService { /// `sessionNotActive` and protects every saved path from cleanup. func privateReceiverPathSelection(publicKey: String, savedReceiverPaths: [String]) async throws -> PrivateReceiverPathSelection { let paths = Self.mergedReceiverPaths(savedReceiverPaths) - let instance: PaykitSdk? = if let sdk { - sdk - } else { - try await operationLock.withCancellableLock { try? handle() } - } - guard let instance else { - return PrivateReceiverPathSelection( - linkableReceiverPaths: [], - publishableReceiverPaths: [], - cleanupProtectedReceiverPaths: paths, - error: PubkyServiceError.sessionNotActive - ) - } - - return try await publicReadSlots.withSlot(.bulk) { - var linkable: [String] = [] - var publishable: [String] = [] - var cleanupProtected: [String] = [] - var firstError: Error? + return try await operationLock.withoutLock { + let instance: PaykitSdk? = if let sdk { + sdk + } else { + try await operationLock.withCancellableLock { try? handle() } + } + guard let instance else { + return PrivateReceiverPathSelection( + linkableReceiverPaths: [], + publishableReceiverPaths: [], + cleanupProtectedReceiverPaths: paths, + error: PubkyServiceError.sessionNotActive + ) + } - for path in paths { - do { - let marker = try await instance.paykitReceiverMarker(publicKey: publicKey, receiverPath: path) - if Self.requiresPrivateLink(marker: marker) { - linkable.append(path) - } - if Self.canReceivePrivatePaymentDetails(marker: marker) { - publishable.append(path) + return try await publicReadSlots.withSlot(.bulk) { + var linkable: [String] = [] + var publishable: [String] = [] + var cleanupProtected: [String] = [] + var firstError: Error? + + for path in paths { + do { + let marker = try await instance.paykitReceiverMarker(publicKey: publicKey, receiverPath: path) + if Self.requiresPrivateLink(marker: marker) { + linkable.append(path) + } + if Self.canReceivePrivatePaymentDetails(marker: marker) { + publishable.append(path) + } + } catch is CancellationError { + throw CancellationError() + } catch { + cleanupProtected.append(path) + firstError = firstError ?? error } - } catch is CancellationError { - throw CancellationError() - } catch { - cleanupProtected.append(path) - firstError = firstError ?? error } - } - return PrivateReceiverPathSelection( - linkableReceiverPaths: linkable, - publishableReceiverPaths: publishable, - cleanupProtectedReceiverPaths: cleanupProtected, - error: firstError - ) + return PrivateReceiverPathSelection( + linkableReceiverPaths: linkable, + publishableReceiverPaths: publishable, + cleanupProtectedReceiverPaths: cleanupProtected, + error: firstError + ) + } } } @@ -1161,17 +1163,21 @@ actor PaykitSdkService { /// that fetch unauthenticated public Pubky data; in paykit rc56 those use the SDK's public client and never load /// session access, the local secret or the state blob. Session, secret or state-blob operations must never use it. /// Without an SDK it takes `operationLock` only to build one, cancellably, and releases it before the read, so a - /// read never builds an SDK outside the lock and never holds the lock across the network call. + /// read never builds an SDK outside the lock and never holds the lock across the network call. Like a locked call, a + /// read that starts during a wallet wipe is rejected, and one that a wipe overtakes fails instead of returning its + /// result across the wipe. private func withPublicRead( priority: PaykitPublicReadPriority = .interactive, _ read: (PaykitSdk) async throws -> T ) async throws -> T { - let instance: PaykitSdk = if let sdk { - sdk - } else { - try await operationLock.withCancellableLock { try handle() } + try await operationLock.withoutLock { + let instance: PaykitSdk = if let sdk { + sdk + } else { + try await operationLock.withCancellableLock { try handle() } + } + return try await publicReadSlots.withSlot(priority) { try await read(instance) } } - return try await publicReadSlots.withSlot(priority) { try await read(instance) } } private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { @@ -1408,6 +1414,17 @@ final class PaykitSdkOperationLock: @unchecked Sendable { try admit() } + /// Runs `operation` without the lock but under the wallet wipe admission of `withLock`: it is rejected while a wipe + /// is in progress. A wipe cannot drain work that does not hold the lock, so a result that a wipe overtakes is + /// discarded and the caller gets the wipe error instead. + func withoutLock(_ operation: () async throws -> T) async throws -> T { + if ownsWipe() { return try await operation() } + let admittedGeneration = try admit() + let result = try await operation() + try validate(admittedGeneration) + return result + } + func withWalletWipe(_ operation: () async throws -> T) async throws -> T { let wipeID = try beginWipe() defer { endWipe() } diff --git a/BitkitTests/PaykitPublicReadLaneTests.swift b/BitkitTests/PaykitPublicReadLaneTests.swift index 3e4164cf3..fdbd96ed1 100644 --- a/BitkitTests/PaykitPublicReadLaneTests.swift +++ b/BitkitTests/PaykitPublicReadLaneTests.swift @@ -99,6 +99,115 @@ final class PaykitPublicReadLaneTests: XCTestCase { } } + func testPublicReadWaitingToBuildAnSdkWhenAWipeStartsFailsWithoutBuildingOne() async throws { + let sdk = WipeRaceSdk(noPointer: .init()) + await sdk.gate.open() + let builds = BuildCount() + let bootstrap = HeldApprovalBootstrap(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { + builds.increment() + return sdk + }) { _, _ in bootstrap } + let approval = Task { + try await service.approveAuth( + authUrl: wipeRaceAuthURL, expectedCapabilities: "/pub/example/:rw", approvedClientID: "paykit.test", + secretKeyHex: String(repeating: "01", count: 32) + ) + } + try await bootstrap.started.waitForEntries(count: 1) + let read = Task { try await service.fetchPubkyFollows(publicKey: "follower") } + try await Task.sleep(for: .milliseconds(50)) + let wipe = Task { try await service.withWalletWipe { builds.value } } + try await Task.sleep(for: .milliseconds(50)) + + await bootstrap.gate.open() + try await approval.value + await assertWipeError(read.result, "A read queued to build an SDK must not build or read once a wipe starts") + let buildsByWipe = try await wipe.value + XCTAssertEqual(buildsByWipe, 0) + XCTAssertEqual(builds.value, 0, "Only work under the lock may build an SDK, and none was admitted") + let reads = await sdk.log.entries + XCTAssertEqual(reads, []) + } + + func testPublicReadsDuringAWipeAreRejectedExceptForTheWipeItself() async throws { + let sdk = WipeRaceSdk(noPointer: .init()) + await sdk.gate.open() + let builds = BuildCount() + let service = PaykitSdkService(sdkFactory: { + builds.increment() + return sdk + }) + _ = try await service.contactRecords() + let (cleanupStarted, startCleanup) = AsyncStream.makeStream() + let (wipeGate, releaseWipe) = AsyncStream.makeStream() + let wipe = Task { + try await service.withWalletWipe { + _ = try await service.contactRecords() + let follows = try await service.fetchPubkyFollows(publicKey: "follower") + XCTAssertEqual(follows, ["follow"], "The wipe's own public reads must still run") + startCleanup.yield() + for await _ in wipeGate { + break + } + } + } + for await _ in cleanupStarted { + break + } + + do { + _ = try await service.fetchPubkyFollows(publicKey: "follower") + XCTFail("Expected a public read during a wipe to be rejected") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "wallet_wipe_in_progress") + } + do { + _ = try await service.privateReceiverPathSelection(publicKey: "follower", savedReceiverPaths: [PaykitReceiverPath.server]) + XCTFail("Expected a receiver path selection during a wipe to be rejected") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "wallet_wipe_in_progress") + } + let reads = await sdk.log.entries + XCTAssertEqual(reads, ["follows:follower"], "Only the wipe's own read may reach the wiped wallet's SDK") + XCTAssertEqual(builds.value, 2) + + releaseWipe.yield() + try await wipe.value + let follows = try await service.fetchPubkyFollows(publicKey: "follower") + XCTAssertEqual(follows, ["follow"]) + XCTAssertEqual(builds.value, 3, "A read after the wipe builds a fresh SDK from the cleared state") + } + + func testPublicReadThatAWipeOvertakesFailsWithoutDelayingTheWipe() async throws { + let sdk = WipeRaceSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + _ = try await service.contactRecords() + let read = Task { try await service.fetchPubkyFollows(publicKey: "follower") } + try await sdk.log.waitForEntries(count: 1) + + try await service.withWalletWipe {} + await sdk.gate.open() + + await assertWipeError(read.result, "A read that a wipe overtook must not return its result across the wipe") + } + + private func assertWipeError( + _ result: Result, + _ message: String, + file: StaticString = #filePath, + line: UInt = #line + ) async { + switch result { + case .success: + XCTFail(message, file: file, line: line) + case let .failure(PaykitError.Storage(code, _)): + XCTAssertEqual(code, "wallet_wipe_in_progress", message, file: file, line: line) + case let .failure(error): + XCTFail("\(message): got \(error)", file: file, line: line) + } + } + /// Expects five bulk reads to have been started, each blocked on its first network read. Four may run, a contact /// record read under the SDK lock and an interactive fetch must both finish meanwhile, then the gate opens. private func assertBulkReadsLeaveTheLockAndReadSlotsFree( @@ -204,3 +313,58 @@ private final class PublicReadLaneSdk: PaykitSdk, @unchecked Sendable { [] } } + +private let wipeRaceAuthURL = + "pubkyauth://signin_grant?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/" + + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + + "&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + +/// Counts the SDKs a service builds. The factory runs on the service actor while the test reads the count. +private final class BuildCount: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.lock() + defer { lock.unlock() } + return count + } + + func increment() { + lock.lock() + count += 1 + lock.unlock() + } +} + +/// Holds an auth approval, which runs under the SDK lock without building an SDK, until its gate opens. +private final class HeldApprovalBootstrap: PubkySessionBootstrap, @unchecked Sendable { + let started = PublicReadLog() + let gate = PublicReadGate() + + override func approveAuth(authUrl _: String, expectedCapabilities _: String, localSecretKey _: PubkyLocalSecretKey) async throws { + await started.record("approval") + await gate.wait() + } +} + +/// Answers follow lookups once its gate opens and records each one that starts. +private final class WipeRaceSdk: PaykitSdk, @unchecked Sendable { + let log = PublicReadLog() + let gate = PublicReadGate() + + override func fetchPubkyFollows(publicKey: String) async throws -> [String] { + await log.record("follows:\(publicKey)") + await gate.wait() + return ["follow"] + } + + override func paykitReceiverMarker(publicKey: String, receiverPath: String) async throws -> PaykitReceiverMarker? { + await log.record("marker:\(publicKey):\(receiverPath)") + return nil + } + + override func contactRecords() async throws -> [ContactRecord] { + [] + } +} diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index ef9cd2fda..afa9c5cb3 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -68,6 +68,72 @@ final class PaykitSdkOperationLockTests: XCTestCase { XCTAssertEqual(events, ["active", "cleanup", "fresh"]) } + func testUnlockedWorkSkipsTheLockButNotWipeAdmission() async throws { + let lock = PaykitSdkOperationLock() + let (activeGate, releaseActive) = AsyncStream.makeStream() + let (activeStarted, startActive) = AsyncStream.makeStream() + let active = Task { + try await lock.withLock { + startActive.yield() + for await _ in activeGate { + break + } + } + } + for await _ in activeStarted { + break + } + let unlocked = try await lock.withoutLock { "unlocked" } + XCTAssertEqual(unlocked, "unlocked", "Unlocked work must not wait for the lock") + + let (overtakenGate, releaseOvertaken) = AsyncStream.makeStream() + let (overtakenStarted, startOvertaken) = AsyncStream.makeStream() + let overtaken = Task { + try await lock.withoutLock { + startOvertaken.yield() + for await _ in overtakenGate { + break + } + return "stale" + } + } + for await _ in overtakenStarted { + break + } + let (wipeGate, releaseWipe) = AsyncStream.makeStream() + let wipe = Task { + try await lock.withWalletWipe { + let owner = try await lock.withoutLock { "owner" } + XCTAssertEqual(owner, "owner", "The wipe's own unlocked work must still run") + for await _ in wipeGate { + break + } + } + } + try await waitUntilWiping(lock) + + do { + try await lock.withoutLock { XCTFail("Unlocked work ran during a wipe") } + XCTFail("Expected unlocked work during a wipe to be rejected") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "wallet_wipe_in_progress") + } + releaseOvertaken.yield() + do { + _ = try await overtaken.value + XCTFail("Expected unlocked work that a wipe overtook to fail instead of returning its result") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "wallet_wipe_in_progress") + } + + releaseActive.yield() + releaseWipe.yield() + try await active.value + try await wipe.value + let fresh = try await lock.withoutLock { "fresh" } + XCTAssertEqual(fresh, "fresh") + } + func testCancelledWipeDrainsActiveWorkWithoutRunningCleanup() async throws { let lock = PaykitSdkOperationLock() let (activeGate, releaseActive) = AsyncStream.makeStream() @@ -171,4 +237,14 @@ final class PaykitSdkOperationLockTests: XCTestCase { let events = await recorder.events XCTAssertEqual(events, ["payment"]) } + + private func waitUntilWiping(_ lock: PaykitSdkOperationLock) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while (try? lock.walletGeneration()) != nil { + guard ContinuousClock.now < deadline else { + return XCTFail("The wipe never started") + } + try await Task.sleep(for: .milliseconds(5)) + } + } } From 772ed13cf431c2eb794309d3c7b145b0684cd99b Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 19:00:43 +0200 Subject: [PATCH 38/79] docs: narrow the contact import journey to an import you leave #851 adds journeys for importing all and selected contacts, which cover saving the prepared follows. contact-import.xml becomes contact-import-after-leaving.xml and keeps only what those do not: an import keeps saving after you leave the overview and, when it finishes, does not take you to Pay Contacts. The Contacts list journey now points at import-all-contacts.xml for its precondition. --- journeys/README.md | 13 ++--- journeys/pubky-profile/README.md | 58 +++++++++---------- .../contact-import-after-leaving.xml | 28 +++++++++ journeys/pubky-profile/contact-import.xml | 29 ---------- .../pubky-profile/contacts-list-loading.xml | 4 +- 5 files changed, 64 insertions(+), 68 deletions(-) create mode 100644 journeys/pubky-profile/contact-import-after-leaving.xml delete mode 100644 journeys/pubky-profile/contact-import.xml diff --git a/journeys/README.md b/journeys/README.md index 54c742504..fe712e162 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -189,13 +189,12 @@ iOS-only — a Bitkit-specific OS handoff into watch-only consent, with no Andro `profile/signup-create-profile.xml` is currently iOS-only. It covers the iOS Profile Error regression after staging signup. Android also supports this signup flow, but this journey has not been ported there. -`pubky-profile/contact-import.xml` and `pubky-profile/contacts-list-loading.xml` are new on both -platforms at once. synonymdev/bitkit-android#1399 carries both journeys with the same file names, -journey names and prose, changing only the identifiers and commands the platform forces, and adds the -import testTags `ContactImportOverviewSelect`, `ContactImportOverviewImportAll` and -`ContactImportSelectContinue`; once it merges, the two platforms share the journeys and those -identifiers. Android master has neither until then. The identifiers the journeys use that still -differ on Android are in the Identifiers table above. +`pubky-profile/contact-import-after-leaving.xml` and `pubky-profile/contacts-list-loading.xml` are +new on both platforms at once. synonymdev/bitkit-android#1399 carries both journeys with the same file +names, journey names and prose, changing only the identifiers and commands the platform forces, and +adds the import testTag `ContactImportOverviewImportAll`; once it merges, the two platforms share the +journeys and that identifier. Android master has neither until then. The identifiers the journeys use +that still differ on Android are in the Identifiers table above. A journey is a shared spec, so a behaviour that is meant to match Android can be checked by running the same file on both sides: `xcodebuildmcp` here, the `android` CLI against a `bitkit-android` diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index d2c803c00..54bf06dd9 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -1,7 +1,8 @@ # Pubky profile loading -This suite covers what Profile, the Pubky Ring choice screen, the contact import and Contacts show -while profiles are still loading from the network. Where Android differs, see +This suite covers what Profile, the Pubky Ring choice screen and Contacts show while profiles are +still loading from the network, and a contact import that finishes after you leave it. Importing +contacts itself is covered by [`journeys/contacts`](../contacts/README.md). Where Android differs, see [iOS vs Android](#ios-vs-android). - `cached-profile-header.xml` opens Profile straight after a relaunch, while the signed-in profile is @@ -13,12 +14,9 @@ while profiles are still loading from the network. Where Android differs, see identity has no profile. A lookup for an identity with no profile can take several seconds to give up. Tapping a row adopts that identity: only the tapped row shows a spinner, and every row is disabled until adoption finishes. -- `contact-import.xml` adopts a Ring identity that follows other pubkys and imports its follows with - Import All. The import saves the profiles the overview already looked up instead of looking every - follow up again, so it takes seconds rather than tens of seconds. A follow with no published - profile is imported under its truncated key. While the import runs, Select and Import All are - disabled. Leaving the screen does not stop the import, and an import that finishes after you left - does not take you to Pay Contacts. +- `contact-import-after-leaving.xml` adopts a Ring identity with many follows, taps Import All and + leaves the import overview straight away. Leaving does not stop the import, and an import that + finishes after you left does not take you to Pay Contacts. Contacts then lists every follow. - `contacts-list-loading.xml` relaunches and opens Contacts. The saved contacts show straight away under the names they were saved with, and each fills in its profile name and avatar as its lookup finishes. Reopening Contacts in the same session shows the profiles already found straight away. @@ -48,13 +46,14 @@ header. Without Pubky Ring, run the steps as a manual test and name the missing capability in the PR. Tapping a row signs in with that Ring identity, so use identities you are happy to adopt. -**Contact import.** The Ring setup above, with an identity that follows at least five pubkys on -pubky.app, at least one of them with no published profile, and no Pubky identity in Bitkit. The -journey saves the follows as contacts; sign out in Bitkit before running it again. +**Contact import after leaving.** The Ring setup above, with an identity that follows many pubkys +on pubky.app (62, as in [`import-all-contacts.xml`](../contacts/import-all-contacts.xml)), so the +import takes long enough to leave, and no Pubky identity in Bitkit. The journey saves the follows as +contacts; sign out in Bitkit before running it again. **Contacts list loading.** A Pubky identity with at least five saved contacts, at least one with a -published profile name and bio and one with no published profile. Running the contact import journey -first leaves exactly that if one of the follows publishes a bio. +published profile name and bio and one with no published profile. Importing such follows with +[`import-all-contacts.xml`](../contacts/import-all-contacts.xml) leaves exactly that. ## Timing @@ -62,8 +61,10 @@ Every loading state here can finish faster than a snapshot round trip on a warm journey reports that ("already loaded", "already resolved", "already adopted" or "already imported") rather than failing. The cached-header journey repeats the relaunch once, and the others continue. -A follow or contact with no published profile is the slow case: its lookup can take several seconds -to give up, and before this change the import and Contacts both waited for every such lookup. +A contact with no published profile is the slow case: its lookup can take several seconds to give +up, and before this change Contacts waited for every such lookup before showing any row. A contact +import saves only to the device, so it can finish before you leave it; the journey then reports +"already imported". Opening Profile in the first few seconds after launch can show a bare spinner, with no "Profile" title, before the cached header. That spinner is the Pubky initialization wait in `MainNavView`. The @@ -76,18 +77,16 @@ header showed. - **Profile while loading.** iOS shows the cached name and avatar read-only under `ProfileCachedHeader`, with no edit, copy, share, QR code or tag controls, and then the full profile under `ProfileViewName`. -- **Contact import and Contacts list.** `contact-import.xml` and `contacts-list-loading.xml` are new - on both platforms at once: synonymdev/bitkit-android#1399 adds both to Android with the same file - names, journey names and prose, changing only identifiers and `adb` commands, and adds the - testTags `ContactImportOverviewSelect`, `ContactImportOverviewImportAll` and - `ContactImportSelectContinue`, so once it merges the two platforms share the journeys and those - identifiers. Android master has neither until then. `Contact_`, `HeaderMenu`, - `DrawerContacts`, `PayContactsContinue`, `ContactViewNotes`, `ContactEdit`, `ProfileEditCancel` - and `NavigationBack` already match Android. Android has no identifier for the overview profile and - summary (`ContactImportOverviewProfile`, `ContactImportOverviewSummary`) or the selection rows - (`ContactImportSelect_`), and names the Ring rows differently; see - [Identifiers](../README.md#identifiers). iOS has always kept a follow with no published profile in - the import; it shows under its truncated key. +- **Contact import after leaving and Contacts list.** `contact-import-after-leaving.xml` and + `contacts-list-loading.xml` are new on both platforms at once: synonymdev/bitkit-android#1399 adds + both to Android with the same file names, journey names and prose, changing only identifiers and + `adb` commands, and adds the testTag `ContactImportOverviewImportAll`, so once it merges the two + platforms share the journeys and that identifier. Android master has neither until then. + `Contact_`, `HeaderMenu`, `DrawerContacts`, `PayContactsContinue`, `ContactViewNotes`, + `ContactEdit`, `ProfileEditCancel` and `NavigationBack` already match Android. Android has no + identifier for the overview profile and summary (`ContactImportOverviewProfile`, + `ContactImportOverviewSummary`) and names the Ring rows differently; see + [Identifiers](../README.md#identifiers). - **Ring choice rows.** Android names the rows and their lookup spinners differently; see [Identifiers](../README.md#identifiers). iOS keeps the rows up, spins only the tapped row, and tags each row `PubkyChoiceRing_`, using the bare z32 key without the `pubky` prefix. @@ -106,8 +105,7 @@ header showed. `PubkyChoiceRingLookup_`, and the create option `PubkyChoiceCreate`. - After adopting: the contact import overview `ContactImportOverviewProfile`, or Pay Contacts `PayContactsContinue`. -- Contact import: overview summary `ContactImportOverviewSummary`, Select `ContactImportOverviewSelect` - and Import All `ContactImportOverviewImportAll`; selection rows `ContactImportSelect_` and - Continue `ContactImportSelectContinue`. +- Contact import: overview summary `ContactImportOverviewSummary`, Import All + `ContactImportOverviewImportAll` and Back `NavigationBack`. - Contacts: the menu button `HeaderMenu` and drawer item `DrawerContacts`; contact rows `Contact_`, using the full key with its `pubky` prefix. diff --git a/journeys/pubky-profile/contact-import-after-leaving.xml b/journeys/pubky-profile/contact-import-after-leaving.xml new file mode 100644 index 000000000..83966d8ed --- /dev/null +++ b/journeys/pubky-profile/contact-import-after-leaving.xml @@ -0,0 +1,28 @@ + + + A contact import keeps saving after you leave the import overview, and when it finishes it leaves + you where you went instead of opening Pay Contacts. Saving the prepared follows without looking + them up again is covered by journeys/contacts/import-all-contacts.xml and + import-selected-contacts.xml. + + Precondition (not a listed capability): Pubky Ring installed on the same simulator or device and + signed by the same Apple team, with an identity that follows many pubkys on pubky.app (62, as in + import-all-contacts.xml), so the import takes long enough to leave. An onboarded E2E Bitkit build + with Paykit UI enabled and no Pubky identity in Bitkit. The journey adopts that identity and saves + its follows as contacts, so sign out of it in Bitkit before running the journey again. See + README.md. + + The import saves only to the device and can finish before Back is tapped; if Pay Contacts opens + first, report "already imported" rather than failing. + + + Tap the header profile button (id "ProfileButton"); if the profile intro (id "ProfileIntro") shows, tap Continue (id "ProfileIntro-button") + Tap the Ring row (id "PubkyChoiceRing_<pubky>") of the identity that follows many pubkys + Wait for the contact import overview (id "ContactImportOverviewProfile", predicate exists) and note the number of friends the summary (id "ContactImportOverviewSummary") shows + Tap Import All (id "ContactImportOverviewImportAll") and straight away tap Back (id "NavigationBack"). If Pay Contacts (id "PayContactsContinue") shows instead, the import finished first: report "already imported" and stop + Verify Home shows (id "ProfileButton", predicate exists) + Wait ten seconds and verify Home still shows and Pay Contacts (id "PayContactsContinue") has not opened + Tap the menu button (id "HeaderMenu"), then Contacts (id "DrawerContacts"); if the contacts intro shows, tap its continue button + Verify Contacts lists one row (id "Contact_<pubky>") for each of the friends the overview counted + + diff --git a/journeys/pubky-profile/contact-import.xml b/journeys/pubky-profile/contact-import.xml deleted file mode 100644 index 60d3d5578..000000000 --- a/journeys/pubky-profile/contact-import.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - After adopting a Pubky Ring identity that follows other pubkys on pubky.app, the contact import - overview lists those follows. Import All saves every follow with the profile the overview - already looked up, without looking each one up again, so it finishes in a few seconds. A follow - with no published profile is imported too, under its truncated key. While the import runs, Select - and Import All are disabled, and leaving the screen does not stop the import. - - Precondition (not a listed capability): Pubky Ring installed on the same simulator or device and - signed by the same Apple team, with an identity that follows at least five pubkys on pubky.app, - at least one of them with no published profile. An onboarded E2E Bitkit build with Paykit UI - enabled and no Pubky identity in Bitkit. The journey adopts that identity and saves its follows - as contacts, so sign out of it in Bitkit before running the journey again. See README.md. - - A warm network can finish the import before the screen is inspected; report "already imported" - rather than failing. - - - Tap the header profile button (id "ProfileButton"); if the profile intro (id "ProfileIntro") shows, tap Continue (id "ProfileIntro-button") - Tap the Ring row (id "PubkyChoiceRing_<pubky>") of the identity that follows other pubkys - Wait for the contact import overview (id "ContactImportOverviewProfile", predicate exists) and note the number of friends the summary (id "ContactImportOverviewSummary") shows - Tap Import All (id "ContactImportOverviewImportAll") and note the time - While Import All shows its spinner, verify Select (id "ContactImportOverviewSelect") is disabled: tapping it does not open the selection screen. If Pay Contacts (id "PayContactsContinue") already shows, the import finished first: report "already imported" and continue - Wait for Pay Contacts (id "PayContactsContinue", predicate exists) and report how long the import took from the tap - Tap Continue (id "PayContactsContinue") and wait for Profile to open - Tap the menu button (id "HeaderMenu"), then Contacts (id "DrawerContacts"); if the contacts intro shows, tap its continue button - Verify Contacts lists one row (id "Contact_<pubky>") for each of the friends the overview counted, and that a follow with no published profile shows its truncated key as its name - - diff --git a/journeys/pubky-profile/contacts-list-loading.xml b/journeys/pubky-profile/contacts-list-loading.xml index 4023b3244..9015d4d55 100644 --- a/journeys/pubky-profile/contacts-list-loading.xml +++ b/journeys/pubky-profile/contacts-list-loading.xml @@ -10,8 +10,8 @@ Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Pubky identity with at least five saved contacts, at least one of them with a published profile name and bio and one - with no published profile. The contact import journey leaves exactly that. Use the test simulator UDID - for <UDID>. + with no published profile. Importing such follows with journeys/contacts/import-all-contacts.xml + leaves exactly that. Use the test simulator UDID for <UDID>. A warm network can resolve every profile before the first snapshot; report "already resolved" rather than failing. See README.md. From 589bcdb931be81f02d4fc3db6f0e08bb1815eff7 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 19:00:43 +0200 Subject: [PATCH 39/79] docs(changelog): leave the contact import speed-up to #851's fragment --- changelog.d/next/854.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/854.fixed.md b/changelog.d/next/854.fixed.md index e9a0a6ef7..4ba251210 100644 --- a/changelog.d/next/854.fixed.md +++ b/changelog.d/next/854.fixed.md @@ -1 +1 @@ -Pubky profiles and contacts load faster: Pubky Ring identities appear at once, adopting one no longer waits on other lookups, the profile screen shows your cached name and avatar while it loads, importing contacts no longer looks every profile up again, and Contacts shows your saved contacts straight away while their profiles load. +Pubky profiles and contacts load faster: Pubky Ring identities appear at once, adopting one no longer waits on other lookups, the profile screen shows your cached name and avatar while it loads, and Contacts shows your saved contacts straight away while their profiles load. A contact import that finishes after you leave it no longer takes you to Pay Contacts. From 7988037bfd43548f60e7bc87528d3f5dba9871f2 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 19:01:16 +0200 Subject: [PATCH 40/79] test: drop the generic parameter from the public read wipe assertion --- BitkitTests/PaykitPublicReadLaneTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BitkitTests/PaykitPublicReadLaneTests.swift b/BitkitTests/PaykitPublicReadLaneTests.swift index fdbd96ed1..ec62fc5b8 100644 --- a/BitkitTests/PaykitPublicReadLaneTests.swift +++ b/BitkitTests/PaykitPublicReadLaneTests.swift @@ -192,8 +192,8 @@ final class PaykitPublicReadLaneTests: XCTestCase { await assertWipeError(read.result, "A read that a wipe overtook must not return its result across the wipe") } - private func assertWipeError( - _ result: Result, + private func assertWipeError( + _ result: Result<[String], Error>, _ message: String, file: StaticString = #filePath, line: UInt = #line From c13b34e0c3c0b4f04d0d8236744724c37bc60b69 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 19:31:49 +0200 Subject: [PATCH 41/79] fix: stop a contact import that a sign-out overtakes An import outlives its screens. When a sign-out, wipe or identity change reset the contacts while it ran, it kept saving the old identity's follows, added them to the cleared list and reported the saves that then failed as an import error. It now stops before its next save and returns quietly. --- Bitkit/Managers/ContactsManager.swift | 13 +++++++---- BitkitTests/ContactsManagerTests.swift | 31 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 84308f55b..5173b01a3 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -520,6 +520,8 @@ class ContactsManager: ObservableObject { /// Remembers the profiles it saves for this session, so the Contacts list shows them at once rather than only their /// saved labels. A placeholder for a follow whose lookup failed is not remembered, so that profile is still looked up. + /// An import outlives its screens, so a reset or another identity's load while it runs, such as after a sign-out, + /// stops it quietly: it saves nothing more, adds nothing to the next session's list and reports no error. func importContacts( contacts selected: [PubkyContact], saveContact: (String, String) async throws -> Void = { publicKey, label in @@ -533,6 +535,7 @@ class ContactsManager: ObservableObject { for contact in selected { try Task.checkCancellation() + guard profilesGeneration == resolvedProfilesGeneration else { break } guard !existingKeys.contains(contact.publicKey) else { continue } do { // The preview already resolved this profile. Receiver discovery runs during contact refresh. @@ -551,10 +554,12 @@ class ContactsManager: ObservableObject { } try Task.checkCancellation() - if profilesGeneration == resolvedProfilesGeneration { - for contact in imported where !contact.profile.isPlaceholder { - rememberResolvedProfile(contact.profile, for: contact.publicKey) - } + guard profilesGeneration == resolvedProfilesGeneration else { + Logger.info("Stopped a contact import that a reset overtook after \(imported.count) saves", context: "ContactsManager") + return + } + for contact in imported where !contact.profile.isPlaceholder { + rememberResolvedProfile(contact.profile, for: contact.publicKey) } let currentKeys = Set(contacts.map(\.publicKey)) contacts.append(contentsOf: imported.filter { !currentKeys.contains($0.publicKey) }) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index c89d90e2c..c636c8f08 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -920,6 +920,37 @@ final class ContactsManagerTests: XCTestCase { await manager.waitForProfileRefreshForTesting() } + func testImportThatAResetOvertakesStopsSavingAndReportsNothing() async throws { + let prepared = (0 ..< 3).map { makeContact(publicKey: "pubky-contact-\($0)") } + // The save running when the user signs out may land first or fail after the sign-out; later saves would fail. + for heldSaveSucceeds in [true, false] { + let manager = ContactsManager() + try await manager.loadContacts(for: "owner", fetchContactRecords: { [] }, fetchRemoteProfile: { _ in nil }) + let saves = HeldProfileLookups(profiles: heldSaveSucceeds ? [prepared[0].publicKey: "Alice"] : [:]) + await saves.hold() + let importTask = Task { + try await manager.importContacts(contacts: prepared) { key, _ in + _ = try await saves.fetch(key) + } + } + while await saves.heldCount < 1 { + await Task.yield() + } + + manager.reset() + await saves.release() + do { + try await importTask.value + } catch { + XCTFail("An import a reset overtook must not report an error after the sign-out: \(error)") + } + + let attempted = await saves.fetchedKeys + XCTAssertEqual(attempted, [prepared[0].publicKey], "A reset stops the import before its next save") + XCTAssertTrue(manager.contacts.isEmpty, "An import a reset overtook adds nothing to the cleared list") + } + } + func testShouldDiscardPendingImportWhenLeavingImportFlow() { XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .contacts)) XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportSelect, destination: nil)) From 751e02e97dd6ec1d1a1aff79acc080252bf63d4f Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 19:31:49 +0200 Subject: [PATCH 42/79] refactor: drop the unused read lane from contact receiver discovery Only the removed import save path asked for the bulk lane, so adding and refreshing a contact go back to master's interactive default. --- Bitkit/Managers/ContactsManager.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 5173b01a3..b3d04b058 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -882,12 +882,9 @@ class ContactsManager: ObservableObject { return true } - private nonisolated static func relevantReceiverPaths( - for publicKey: String, - priority: PaykitPublicReadPriority = .interactive - ) async throws -> [String] { + private nonisolated static func relevantReceiverPaths(for publicKey: String) async throws -> [String] { do { - return try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey, priority: priority) + return try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey) } catch is CancellationError { throw CancellationError() } catch { From 427f1d5860d8a974a26419fb2623e73e2675ac5d Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 19:36:23 +0200 Subject: [PATCH 43/79] test: check that an import finishing after you leave stays where you went The contact import UI fixture can now leave the import flow while a save is held, so the overview and selection screens are both checked to open Pay Contacts only for an import that is still pending. --- .../Testing/ContactImportUITestFixture.swift | 6 +++ BitkitUITests/ContactImportUITests.swift | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift b/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift index 655e4bcb7..e21364973 100644 --- a/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift +++ b/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift @@ -18,6 +18,12 @@ } .disabled(!manager.isSavePending) .accessibilityIdentifier("ContactImportFixtureFinishSave") + CustomButton(title: "Leave import", size: .small) { + // In the app, AppScene discards the pending import when the user leaves the import flow. + manager.clearPendingImport() + navigation.path = [] + } + .accessibilityIdentifier("ContactImportFixtureLeave") NavigationStack(path: $navigation.path) { ContactImportOverviewView(profile: manager.fixtureProfile, contacts: manager.fixtureContacts) diff --git a/BitkitUITests/ContactImportUITests.swift b/BitkitUITests/ContactImportUITests.swift index dd33c59b7..f0119beba 100644 --- a/BitkitUITests/ContactImportUITests.swift +++ b/BitkitUITests/ContactImportUITests.swift @@ -57,6 +57,33 @@ final class ContactImportUITests: XCTestCase { finishSave(expectedKeys: "pubky-alice", expectedSaves: 1) } + func testOverviewImportFinishingAfterLeavingDoesNotOpenPayContacts() { + let importAll = app.buttons["ContactImportOverviewImportAll"] + XCTAssertTrue(importAll.waitForExistence(timeout: 10)) + + importAll.tap() + waitForPendingSave() + leaveImport() + + finishSaveAfterLeaving(expectedSaves: 2) + } + + func testSelectionImportFinishingAfterLeavingDoesNotOpenPayContacts() { + let select = app.buttons["ContactImportOverviewSelect"] + XCTAssertTrue(select.waitForExistence(timeout: 10)) + select.tap() + let continueButton = app.buttons["ContactImportSelectContinue"] + XCTAssertTrue(continueButton.waitForExistence(timeout: 5)) + + continueButton.tap() + waitForPendingSave() + leaveImport() + XCTAssertTrue(select.waitForExistence(timeout: 5)) + XCTAssertFalse(continueButton.exists) + + finishSaveAfterLeaving(expectedSaves: 2) + } + private func waitForPendingSave() { let finish = app.buttons["ContactImportFixtureFinishSave"] let pending = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: finish) @@ -77,4 +104,22 @@ final class ContactImportUITests: XCTestCase { XCTAssertEqual(app.staticTexts["ContactImportFixtureCounts"].label, "Imports: 1, saves: \(expectedSaves)") XCTAssertEqual(app.staticTexts["ContactImportFixturePreview"].label, "Preview cleared") } + + private func leaveImport() { + app.buttons["ContactImportFixtureLeave"].tap() + XCTAssertEqual(app.staticTexts["ContactImportFixturePreview"].label, "Preview cleared") + } + + private func finishSaveAfterLeaving(expectedSaves: Int) { + app.buttons["ContactImportFixtureFinishSave"].tap() + let saved = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", "Imports: 1, saves: \(expectedSaves)"), + object: app.staticTexts["ContactImportFixtureCounts"] + ) + XCTAssertEqual(XCTWaiter.wait(for: [saved], timeout: 5), .completed) + XCTAssertFalse( + app.staticTexts["ContactImportFixtureCompleted"].waitForExistence(timeout: 3), + "An import that finishes after the user left must not open Pay Contacts" + ) + } } From 54e13d07341d4b4a71ac8da62ec9c89a6b5c042c Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 19:36:23 +0200 Subject: [PATCH 44/79] docs: name only the contact import identifiers the journeys use Drop the selection row from the identifier table, since no journey uses it, and say that ContactImportOverviewImportAll is the only import testTag the shared journeys need. Note that ContactImportUITests checks leaving an import without depending on its timing. --- journeys/README.md | 8 ++++---- journeys/pubky-profile/README.md | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index fe712e162..e1febe6dd 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -127,7 +127,6 @@ Known naming differences: | Pubky Ring choice row | `PubkyChoiceIdentity` (the same tag on every row; tell rows apart by their key caption) | `PubkyChoiceRing_` (bare z32 key) | | Pubky Ring choice row lookup spinner | `PubkyChoiceIdentityLookup` (the same tag on every row) | `PubkyChoiceRingLookup_` (bare z32 key) | | Contact import overview profile and friend count | — *(no tag; read the "Import" title and the "N friends" text)* | `ContactImportOverviewProfile`, `ContactImportOverviewSummary` | -| Contact import selection row | — | `ContactImportSelect_` | `SubscriptionRow-` matches Android exactly. `PaymentRequestRow` keeps that prefix but appends the billing period (`-one-time` for a one-off), because every recurring payment of one @@ -192,9 +191,10 @@ after staging signup. Android also supports this signup flow, but this journey h `pubky-profile/contact-import-after-leaving.xml` and `pubky-profile/contacts-list-loading.xml` are new on both platforms at once. synonymdev/bitkit-android#1399 carries both journeys with the same file names, journey names and prose, changing only the identifiers and commands the platform forces, and -adds the import testTag `ContactImportOverviewImportAll`; once it merges, the two platforms share the -journeys and that identifier. Android master has neither until then. The identifiers the journeys use -that still differ on Android are in the Identifiers table above. +adds `ContactImportOverviewImportAll`, the only contact import testTag the journeys use; once it +merges, the two platforms share the journeys and that identifier. Android master has neither until +then. The identifiers the journeys use that still differ on Android are in the Identifiers table +above. A journey is a shared spec, so a behaviour that is meant to match Android can be checked by running the same file on both sides: `xcodebuildmcp` here, the `android` CLI against a `bitkit-android` diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 54bf06dd9..77aa5d69f 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -80,8 +80,10 @@ header showed. - **Contact import after leaving and Contacts list.** `contact-import-after-leaving.xml` and `contacts-list-loading.xml` are new on both platforms at once: synonymdev/bitkit-android#1399 adds both to Android with the same file names, journey names and prose, changing only identifiers and - `adb` commands, and adds the testTag `ContactImportOverviewImportAll`, so once it merges the two - platforms share the journeys and that identifier. Android master has neither until then. + `adb` commands, and adds `ContactImportOverviewImportAll`, the only contact import testTag the + journeys use, so once it merges the two platforms share the journeys and that identifier. Android + master has neither until then. On iOS, `ContactImportUITests` also leaves an import while its save + is held, so that check does not depend on the import being slow. `Contact_`, `HeaderMenu`, `DrawerContacts`, `PayContactsContinue`, `ContactViewNotes`, `ContactEdit`, `ProfileEditCancel` and `NavigationBack` already match Android. Android has no identifier for the overview profile and summary (`ContactImportOverviewProfile`, From a34755c2486811b00c028b9013f420dc0d6bc759 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:00:42 +0200 Subject: [PATCH 45/79] fix: wait for a contact's background profile lookup before saving an edit When the interactive lookup of a label-only contact failed while the background refresh was still looking it up, the contact stopped counting as unresolved, so Save went ahead with the label-only row. Renaming it saved its empty bio, avatar and links as a local override, which the background result arriving later could no longer replace. A failed interactive lookup now leaves the contact unresolved and waits for the background lookup of it, and every caller, such as Save and tag changes, waits with it. The row keeps its label only once that lookup fails too. --- Bitkit/Managers/ContactsManager.swift | 38 +++++++++-- BitkitTests/ContactsManagerTests.swift | 89 +++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 17 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index b3d04b058..269309b20 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -118,7 +118,11 @@ class ContactsManager: ObservableObject { private var contactsRevision = 0 private var loadGeneration = 0 private var isApplyingProfileRefresh = false - private var profileRefresh: ContactProfileRefresh? + private var profileRefresh: ContactProfileRefresh? { + didSet { resumeProfileRefreshWaiters() } + } + + private var profileRefreshWaiters: [CheckedContinuation] = [] private var profileRefreshCount = 0 private var pendingProfileLookups: [String: Task] = [:] /// Profiles resolved this session for the owner's contacts, shown in place of stored labels while a load refreshes. @@ -366,7 +370,9 @@ class ContactsManager: ObservableObject { /// Looks a saved contact's profile up on the interactive read lane while its row still shows only its saved label /// because the background refresh has not reached it, so a screen showing that contact does not wait behind bulk /// reads and an edit made there keeps the contact's avatar, bio and links. Returns at once for any other row, and - /// joins a lookup already running for the contact. When the lookup fails, the row keeps its label. + /// joins a lookup already running for the contact. When the lookup fails, the contact stays unresolved and this waits + /// for the background refresh's own lookup of it, so an edit saved afterwards never writes the label-only row over a + /// profile that is still arriving. The row keeps its label only when that lookup fails too. func resolvePendingContactProfile(publicKey: String) async { await resolvePendingContactProfile(publicKey: publicKey, fetchRemoteProfile: remoteProfileLookup(on: .interactive)) } @@ -389,19 +395,36 @@ class ContactsManager: ObservableObject { let generation = resolvedProfilesGeneration let lookup = Task { [weak self] in let profile = try? await Self.resolveContactProfile(publicKey: key, fetchRemoteProfile: fetchRemoteProfile) - self?.finishPendingProfileLookup(of: key, profile: profile?.withNameFallback(label), generation: generation) + await self?.finishPendingProfileLookup(of: key, profile: profile?.withNameFallback(label), generation: generation) } pendingProfileLookups[key] = lookup await lookup.value } - private func finishPendingProfileLookup(of publicKey: String, profile: PubkyProfile?, generation: Int) { + private func finishPendingProfileLookup(of publicKey: String, profile: PubkyProfile?, generation: Int) async { guard generation == resolvedProfilesGeneration else { return } - pendingProfileLookups[publicKey] = nil - profileRefresh?.pendingKeys.remove(publicKey) if let profile { + profileRefresh?.pendingKeys.remove(publicKey) applyResolvedProfile(profile, for: publicKey) + } else { + await waitForRefreshLookup(of: publicKey) + guard generation == resolvedProfilesGeneration else { return } } + pendingProfileLookups[publicKey] = nil + } + + /// Returns once no background refresh is looking `publicKey` up any more: its lookup finished, or the refresh was + /// replaced by one that does not look it up or stopped. + private func waitForRefreshLookup(of publicKey: String) async { + while profileRefresh?.pendingKeys.contains(publicKey) == true { + await withCheckedContinuation { profileRefreshWaiters.append($0) } + } + } + + private func resumeProfileRefreshWaiters() { + let waiters = profileRefreshWaiters + profileRefreshWaiters = [] + waiters.forEach { $0.resume() } } private func applyResolvedProfile(_ profile: PubkyProfile, for publicKey: String) { @@ -763,7 +786,8 @@ class ContactsManager: ObservableObject { } /// A background refresh of saved contacts' profiles. `labels` maps each contact it looks up to its saved label, and - /// `pendingKeys` holds those whose lookup has not finished, whose rows may still show only that label. + /// `pendingKeys` holds those whose lookup has not finished and that no interactive lookup has resolved, whose rows + /// may still show only that label. private struct ContactProfileRefresh { let id: Int let labels: [String: String?] diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index c636c8f08..8608afb5d 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -779,13 +779,70 @@ final class ContactsManagerTests: XCTestCase { let storedAttempts = await interactive.attempts XCTAssertEqual(storedAttempts, 0, "A stored profile is not looked up") - await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + let lookup = Task { + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + } + while await interactive.attempts < 1 { + await Task.yield() + } + await bulk.release() + await lookup.value await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } let attempts = await interactive.attempts XCTAssertEqual(attempts, 1, "A failed lookup is left to the background refresh") - XCTAssertEqual(manager.contacts.map(\.displayName), ["Bob", "Label only"]) + XCTAssertEqual(manager.contacts.map(\.displayName), ["Bob", "Label only"], "A row keeps its label once every lookup fails") + await manager.waitForProfileRefreshForTesting() + } + + func testEditWaitsForTheBackgroundLookupWhenTheContactsOwnLookupFails() async throws { + let manager = ContactsManager() + let published = Bitkit.PubkyProfile( + publicKey: contactProfileKey, + name: "Alice", + bio: "Hello", + imageUrl: "pubky://alice/avatar", + links: [PubkyProfileLink(label: "Site", url: "https://alice.example")], + tags: [], + status: nil + ) + let bulk = HeldProfileLookups(publishedProfiles: [published]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + while await bulk.heldCount < 1 { + await Task.yield() + } + + // Opening the edit screen looks the label-only row up on the interactive lane, and that lookup fails. + let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) + let opening = Task { + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + } + while await interactive.attempts < 1 { + await Task.yield() + } + + // The user renames the row and saves while the background lookup is still running. + var form = ContactEditForm() + try form.fill(from: XCTUnwrap(manager.contacts.first?.profile)) + form.name = "My Alice" + let save = Task { + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + return manager.contacts.first?.profile + } + try await Task.sleep(for: .milliseconds(100)) await bulk.release() + let saved = await save.value + try form.fill(from: XCTUnwrap(saved)) + await opening.value await manager.waitForProfileRefreshForTesting() + + XCTAssertEqual(form.name, "My Alice") + XCTAssertEqual(form.bio, "Hello", "Save must wait for the background lookup, not save the label-only row's empty bio") + XCTAssertEqual(form.imageUrl, "pubky://alice/avatar") + XCTAssertEqual(form.links.map(\.url), ["https://alice.example"]) + let attempts = await interactive.attempts + XCTAssertEqual(attempts, 1, "Save joins the failed lookup's wait instead of looking the contact up again") } func testInteractiveProfileLookupStartedBeforeResetIsIgnored() async throws { @@ -882,10 +939,16 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(alice.profile.imageUrl, "pubky://alice/avatar", "An imported profile shows before the background refresh reaches it") XCTAssertEqual(alice.profile.bio, "Hello") let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) - await manager.resolvePendingContactProfile(publicKey: unresolvedFollowKey) { try await interactive.fetch($0) } + let lookup = Task { + await manager.resolvePendingContactProfile(publicKey: unresolvedFollowKey) { try await interactive.fetch($0) } + } + while await interactive.attempts < 1 { + await Task.yield() + } + await bulk.release() + await lookup.value let attempts = await interactive.attempts XCTAssertEqual(attempts, 1, "A follow imported as a placeholder must still have its profile looked up before an edit") - await bulk.release() await manager.waitForProfileRefreshForTesting() } @@ -1098,10 +1161,10 @@ private let profileTransportError = PaykitError.Transport(code: "transport_error private let unresolvedFollowKey = "pubky" + String(repeating: "y", count: 52) -/// Answers profile lookups with the scripted names, failing for any other key like a key with no profile, and can hold +/// Answers profile lookups with the scripted profiles, failing for any other key like a key with no profile, and can hold /// lookups until released. private actor HeldProfileLookups { - private let profiles: [String: String] + private let profiles: [String: Bitkit.PubkyProfile] private(set) var fetchedKeys: [String] = [] private var isHolding = false private var held: [CheckedContinuation] = [] @@ -1110,8 +1173,14 @@ private actor HeldProfileLookups { held.count } - init(profiles: [String: String]) { - self.profiles = profiles + init(profiles names: [String: String]) { + profiles = Dictionary(uniqueKeysWithValues: names.map { publicKey, name in + (publicKey, Bitkit.PubkyProfile(publicKey: publicKey, name: name, bio: "", imageUrl: nil, links: [], status: nil)) + }) + } + + init(publishedProfiles: [Bitkit.PubkyProfile]) { + profiles = Dictionary(uniqueKeysWithValues: publishedProfiles.map { ($0.publicKey, $0) }) } func hold() { @@ -1129,8 +1198,8 @@ private actor HeldProfileLookups { if isHolding { await withCheckedContinuation { held.append($0) } } - guard let name = profiles[publicKey] else { throw profileTransportError } - return Bitkit.PubkyProfile(publicKey: publicKey, name: name, bio: "", imageUrl: nil, links: [], status: nil) + guard let profile = profiles[publicKey] else { throw profileTransportError } + return profile } } From 4f638838afb0b62080835972ffdbccc9fecf1b54 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:00:42 +0200 Subject: [PATCH 46/79] fix: keep Create Profile open when contact discovery finishes after it After a Ring identity is adopted with a reused row profile, the refresh behind navigation can find that profile removed while contact discovery is still running. Profile setup then opens Create Profile, and when discovery finished it replaced that screen with the import or Pay Contacts, where the setup resume did not reopen it. Profile setup is now checked again after discovery, and while it is pending the discovered route and its import are dropped. --- Bitkit/Views/Profile/PubkyChoiceView.swift | 27 ++++++++++--- BitkitTests/PubkyProfileManagerTests.swift | 44 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 0730f03b8..7f625975b 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -59,6 +59,25 @@ struct PubkyChoiceView: View { } } + /// Contact discovery can outlast the refresh of a reused row profile. When that refresh finds the profile removed, + /// Create Profile opens while discovery runs, so this returns no route then and the discovered one does not replace it. + @MainActor + static func destinationAfterAdoption( + of adopted: PubkyProfile, + pubkyProfile: PubkyProfileManager, + contactsManager: ContactsManager + ) async -> Route? { + let destination = await contactsManager.destinationAfterAuthentication( + profile: pubkyProfile.profile, + publicKey: adopted.publicKey + ) + guard !pubkyProfile.isProfileSetupPending else { + contactsManager.clearPendingImport() + return nil + } + return destination + } + var body: some View { ZStack { backgroundIllustrations @@ -180,11 +199,9 @@ struct PubkyChoiceView: View { return } - let destination = await contactsManager.destinationAfterAuthentication( - profile: pubkyProfile.profile, - publicKey: adopted.publicKey - ) - navigation.path = [destination] + if let destination = await Self.destinationAfterAdoption(of: adopted, pubkyProfile: pubkyProfile, contactsManager: contactsManager) { + navigation.path = [destination] + } } catch is CancellationError { return } catch { diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index bac55474a..becf50bc9 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1499,6 +1499,50 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testContactDiscoveryFinishingAfterProfileSetupStartsDoesNotReplaceCreateProfile() async throws { + for profileWasRemoved in [true, false] { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA]) + if profileWasRemoved { + await stub.setProfile(nil, for: ringKeyA) + } + let follow = makeProfile(publicKey: ringKeyB, name: "Bob") + let (followsGate, openFollows) = AsyncStream.makeStream() + defer { openFollows.finish() } + let contactsManager = ContactsManager( + fetchFollows: { _ in + for await _ in followsGate {} + return [follow.publicKey] + }, + fetchRemoteProfile: { _, _ in follow } + ) + + let adopted = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + let reused = try XCTUnwrap(adopted) + let routing = Task { + await PubkyChoiceView.destinationAfterAdoption(of: reused, pubkyProfile: manager, contactsManager: contactsManager) + } + // The refresh of the reused row profile lands while discovery still waits for the follows. + await stub.waitForRequests(2) + await waitUntil("the refresh of the reused row profile finishes") { !manager.isLoadingProfile } + XCTAssertEqual(manager.isProfileSetupPending, profileWasRemoved) + openFollows.finish() + let destination = await routing.value + + if profileWasRemoved { + XCTAssertNil(destination, "Discovery finishing after Create Profile opened must not replace it") + XCTAssertFalse(contactsManager.hasPendingImport, "The import found for a profile that is gone is dropped") + } else { + XCTAssertEqual(destination, .contactImportOverview) + XCTAssertTrue(contactsManager.hasPendingImport) + } + } + } + } + @MainActor func testAdoptingStopsOnlyTheOtherRowLookupsAndAFailedAdoptReloadsTheRows() async { let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], holdsRequests: true) From e6f2392ac54e2bbef7d2bdd5ed37d2f11028dbc6 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:26:20 +0200 Subject: [PATCH 47/79] fix: let a screen's contact lookup take over its background lookup When a screen's own lookup of a label-only contact failed, Save and tag changes waited for the background refresh's lookup of that contact, even while it was still queued behind the bulk read lane, so they could wait behind every other contact's lookup. The screen's lookup now takes the contact over from the background refresh, which drops its own result for it, so Save and tag changes wait for at most that one interactive lookup and a late background result cannot change the row under an edit. When the lookup fails, the row keeps its label and the edit saves, as on master. The background read itself still runs, since a task group cannot cancel one of its lookups. --- Bitkit/Managers/ContactsManager.swift | 46 ++---- Bitkit/Views/Contacts/ContactDetailView.swift | 4 +- BitkitTests/ContactsManagerTests.swift | 143 ++++++++++-------- 3 files changed, 98 insertions(+), 95 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 269309b20..b0e09702c 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -118,11 +118,7 @@ class ContactsManager: ObservableObject { private var contactsRevision = 0 private var loadGeneration = 0 private var isApplyingProfileRefresh = false - private var profileRefresh: ContactProfileRefresh? { - didSet { resumeProfileRefreshWaiters() } - } - - private var profileRefreshWaiters: [CheckedContinuation] = [] + private var profileRefresh: ContactProfileRefresh? private var profileRefreshCount = 0 private var pendingProfileLookups: [String: Task] = [:] /// Profiles resolved this session for the owner's contacts, shown in place of stored labels while a load refreshes. @@ -353,9 +349,10 @@ class ContactsManager: ObservableObject { profileRefresh = ContactProfileRefresh(id: refreshID, labels: labels, pendingKeys: Set(labels.keys), task: task) } + /// Drops the result for a contact whose lookup a screen took over. A task group cannot cancel one of its lookups, so + /// that contact's read still runs. private func finishRefreshLookup(of publicKey: String, profile: PubkyProfile?, refreshID: Int) { - guard profileRefresh?.id == refreshID else { return } - profileRefresh?.pendingKeys.remove(publicKey) + guard profileRefresh?.id == refreshID, profileRefresh?.pendingKeys.remove(publicKey) != nil else { return } if let profile { applyResolvedProfile(profile, for: publicKey) } @@ -369,10 +366,10 @@ class ContactsManager: ObservableObject { /// Looks a saved contact's profile up on the interactive read lane while its row still shows only its saved label /// because the background refresh has not reached it, so a screen showing that contact does not wait behind bulk - /// reads and an edit made there keeps the contact's avatar, bio and links. Returns at once for any other row, and - /// joins a lookup already running for the contact. When the lookup fails, the contact stays unresolved and this waits - /// for the background refresh's own lookup of it, so an edit saved afterwards never writes the label-only row over a - /// profile that is still arriving. The row keeps its label only when that lookup fails too. + /// reads and an edit made there keeps the contact's avatar, bio and links. The lookup takes the contact over from the + /// background refresh, which then drops its own result for it, so that result cannot change the row under an edit. + /// Returns at once for any other row, and joins a lookup already running for the contact. When the lookup fails, the + /// row keeps its label. func resolvePendingContactProfile(publicKey: String) async { await resolvePendingContactProfile(publicKey: publicKey, fetchRemoteProfile: remoteProfileLookup(on: .interactive)) } @@ -393,40 +390,23 @@ class ContactsManager: ObservableObject { else { return } let generation = resolvedProfilesGeneration + profileRefresh?.pendingKeys.remove(key) let lookup = Task { [weak self] in let profile = try? await Self.resolveContactProfile(publicKey: key, fetchRemoteProfile: fetchRemoteProfile) - await self?.finishPendingProfileLookup(of: key, profile: profile?.withNameFallback(label), generation: generation) + self?.finishPendingProfileLookup(of: key, profile: profile?.withNameFallback(label), generation: generation) } pendingProfileLookups[key] = lookup await lookup.value } - private func finishPendingProfileLookup(of publicKey: String, profile: PubkyProfile?, generation: Int) async { + private func finishPendingProfileLookup(of publicKey: String, profile: PubkyProfile?, generation: Int) { guard generation == resolvedProfilesGeneration else { return } + pendingProfileLookups[publicKey] = nil if let profile { - profileRefresh?.pendingKeys.remove(publicKey) applyResolvedProfile(profile, for: publicKey) - } else { - await waitForRefreshLookup(of: publicKey) - guard generation == resolvedProfilesGeneration else { return } - } - pendingProfileLookups[publicKey] = nil - } - - /// Returns once no background refresh is looking `publicKey` up any more: its lookup finished, or the refresh was - /// replaced by one that does not look it up or stopped. - private func waitForRefreshLookup(of publicKey: String) async { - while profileRefresh?.pendingKeys.contains(publicKey) == true { - await withCheckedContinuation { profileRefreshWaiters.append($0) } } } - private func resumeProfileRefreshWaiters() { - let waiters = profileRefreshWaiters - profileRefreshWaiters = [] - waiters.forEach { $0.resume() } - } - private func applyResolvedProfile(_ profile: PubkyProfile, for publicKey: String) { rememberResolvedProfile(profile, for: publicKey) guard Self.loadContactProfileOverrides()[publicKey] == nil, @@ -786,7 +766,7 @@ class ContactsManager: ObservableObject { } /// A background refresh of saved contacts' profiles. `labels` maps each contact it looks up to its saved label, and - /// `pendingKeys` holds those whose lookup has not finished and that no interactive lookup has resolved, whose rows + /// `pendingKeys` holds those whose lookup has not finished and that no screen's lookup has taken over, whose rows /// may still show only that label. private struct ContactProfileRefresh { let id: Int diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 8637d2a88..45844525b 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -236,8 +236,8 @@ struct ContactDetailView: View { } /// Shows the change at once, then saves it, one tag change at a time, over the contact's latest profile. It first - /// waits for a profile the row is still waiting for, so a tag change never saves a label-only row over the contact's - /// bio, links and avatar. + /// waits for the lookup of a profile the row is still waiting for, so a tag change keeps the bio, links and avatar + /// that lookup finds. private func updateTags(_ transform: @escaping ([String]) -> [String]) { guard let current = profile else { return } profile = current.withTags(transform(current.tags)) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 8608afb5d..8632ffdea 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -740,16 +740,7 @@ final class ContactsManagerTests: XCTestCase { await bulk.hold() let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) - let published = Bitkit.PubkyProfile( - publicKey: contactProfileKey, - name: "Alice", - bio: "Hello", - imageUrl: "pubky://alice/avatar", - links: [PubkyProfileLink(label: "Site", url: "https://alice.example")], - tags: [], - status: nil - ) - let interactive = ContactProfileFetchStub([.success(published)]) + let interactive = ContactProfileFetchStub([.success(publishedContactProfile)]) async let first: Void = manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } async let second: Void = manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } @@ -779,70 +770,98 @@ final class ContactsManagerTests: XCTestCase { let storedAttempts = await interactive.attempts XCTAssertEqual(storedAttempts, 0, "A stored profile is not looked up") - let lookup = Task { - await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } - } - while await interactive.attempts < 1 { - await Task.yield() - } - await bulk.release() - await lookup.value + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } let attempts = await interactive.attempts - XCTAssertEqual(attempts, 1, "A failed lookup is left to the background refresh") - XCTAssertEqual(manager.contacts.map(\.displayName), ["Bob", "Label only"], "A row keeps its label once every lookup fails") + XCTAssertEqual(attempts, 1, "A contact whose own lookup failed is not looked up again") + XCTAssertEqual(manager.contacts.map(\.displayName), ["Bob", "Label only"]) + await bulk.release() await manager.waitForProfileRefreshForTesting() } - func testEditWaitsForTheBackgroundLookupWhenTheContactsOwnLookupFails() async throws { + func testEditSavesWithoutWaitingForTheQueuedBackgroundLookupWhenTheContactsOwnLookupFails() async throws { let manager = ContactsManager() - let published = Bitkit.PubkyProfile( - publicKey: contactProfileKey, - name: "Alice", - bio: "Hello", - imageUrl: "pubky://alice/avatar", - links: [PubkyProfileLink(label: "Site", url: "https://alice.example")], - tags: [], - status: nil - ) - let bulk = HeldProfileLookups(publishedProfiles: [published]) - await bulk.hold() - let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] - try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) - while await bulk.heldCount < 1 { + let slots = PaykitPublicReadSlots() + // Other reads hold every bulk slot, so the background lookup of the contact stays queued behind them. + let otherReads = HeldProfileLookups(profiles: [:]) + await otherReads.hold() + let bulkReads = (0 ..< 4).map { index in + Task { _ = try? await slots.withSlot(.bulk) { try await otherReads.fetch("other\(index)") } } + } + while await otherReads.heldCount < 4 { await Task.yield() } + let bulk = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts( + for: "owner", + fetchContactRecords: { records }, + fetchRemoteProfile: { publicKey in try await slots.withSlot(.bulk) { try await bulk.fetch(publicKey) } } + ) // Opening the edit screen looks the label-only row up on the interactive lane, and that lookup fails. let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) - let opening = Task { - await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + let screenLookup: @Sendable (String) async throws -> Bitkit.PubkyProfile? = { publicKey in + try await slots.withSlot(.interactive) { try await interactive.fetch(publicKey) } } - while await interactive.attempts < 1 { - await Task.yield() + let opened = expectation(description: "The failed lookup returned while the bulk lane was still full") + let opening = Task { + await manager.resolvePendingContactProfile(publicKey: contactProfileKey, fetchRemoteProfile: screenLookup) + opened.fulfill() } + await fulfillment(of: [opened], timeout: 2) - // The user renames the row and saves while the background lookup is still running. + // The user renames the row and saves while the bulk lane is still full. var form = ContactEditForm() try form.fill(from: XCTUnwrap(manager.contacts.first?.profile)) form.name = "My Alice" + let saved = expectation(description: "Save went ahead while the bulk lane was still full") let save = Task { - await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } - return manager.contacts.first?.profile + await manager.resolvePendingContactProfile(publicKey: contactProfileKey, fetchRemoteProfile: screenLookup) + saved.fulfill() } - try await Task.sleep(for: .milliseconds(100)) - await bulk.release() - let saved = await save.value - try form.fill(from: XCTUnwrap(saved)) - await opening.value - await manager.waitForProfileRefreshForTesting() + await fulfillment(of: [saved], timeout: 2) + try form.fill(from: XCTUnwrap(manager.contacts.first?.profile)) XCTAssertEqual(form.name, "My Alice") - XCTAssertEqual(form.bio, "Hello", "Save must wait for the background lookup, not save the label-only row's empty bio") - XCTAssertEqual(form.imageUrl, "pubky://alice/avatar") - XCTAssertEqual(form.links.map(\.url), ["https://alice.example"]) + XCTAssertEqual(form.bio, "", "Once the contact's own lookup fails, Save keeps the label-only row, as on master") + XCTAssertNil(form.imageUrl) let attempts = await interactive.attempts - XCTAssertEqual(attempts, 1, "Save joins the failed lookup's wait instead of looking the contact up again") + XCTAssertEqual(attempts, 1, "Save waits for at most the one interactive lookup") + + await otherReads.release() + for read in bulkReads { + await read.value + } + await opening.value + await save.value + await manager.waitForProfileRefreshForTesting() + } + + func testBackgroundResultForAContactAScreenTookOverIsDropped() async throws { + let manager = ContactsManager() + let bulk = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + while await bulk.heldCount < 1 { + await Task.yield() + } + + let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + + // The background lookup of the contact finds its profile only now, such as while a Save uploads an avatar. + await bulk.release() + await manager.waitForProfileRefreshForTesting() + + let row = try XCTUnwrap(manager.contacts.first?.profile) + XCTAssertEqual(row.name, "Label only", "A background result for a contact a screen took over must not change its row") + XCTAssertEqual(row.bio, "") + XCTAssertNil(row.imageUrl) + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { _ in throw profileTransportError }) + XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "Nor may the next load show it") + await manager.waitForProfileRefreshForTesting() } func testInteractiveProfileLookupStartedBeforeResetIsIgnored() async throws { @@ -939,16 +958,10 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(alice.profile.imageUrl, "pubky://alice/avatar", "An imported profile shows before the background refresh reaches it") XCTAssertEqual(alice.profile.bio, "Hello") let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) - let lookup = Task { - await manager.resolvePendingContactProfile(publicKey: unresolvedFollowKey) { try await interactive.fetch($0) } - } - while await interactive.attempts < 1 { - await Task.yield() - } - await bulk.release() - await lookup.value + await manager.resolvePendingContactProfile(publicKey: unresolvedFollowKey) { try await interactive.fetch($0) } let attempts = await interactive.attempts XCTAssertEqual(attempts, 1, "A follow imported as a placeholder must still have its profile looked up before an edit") + await bulk.release() await manager.waitForProfileRefreshForTesting() } @@ -1161,6 +1174,16 @@ private let profileTransportError = PaykitError.Transport(code: "transport_error private let unresolvedFollowKey = "pubky" + String(repeating: "y", count: 52) +private let publishedContactProfile = Bitkit.PubkyProfile( + publicKey: contactProfileKey, + name: "Alice", + bio: "Hello", + imageUrl: "pubky://alice/avatar", + links: [PubkyProfileLink(label: "Site", url: "https://alice.example")], + tags: [], + status: nil +) + /// Answers profile lookups with the scripted profiles, failing for any other key like a key with no profile, and can hold /// lookups until released. private actor HeldProfileLookups { From 408a7baae5781ffd9e9bc76c6db62570ec2fe95a Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:42:39 +0200 Subject: [PATCH 48/79] docs: say which refresh a screen's contact lookup takes the contact from The doc comment said the background result could not change the row under an edit. Only the refresh running when the screen's lookup starts drops its result for the contact, so say that. --- Bitkit/Managers/ContactsManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index b0e09702c..01d61c698 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -367,9 +367,9 @@ class ContactsManager: ObservableObject { /// Looks a saved contact's profile up on the interactive read lane while its row still shows only its saved label /// because the background refresh has not reached it, so a screen showing that contact does not wait behind bulk /// reads and an edit made there keeps the contact's avatar, bio and links. The lookup takes the contact over from the - /// background refresh, which then drops its own result for it, so that result cannot change the row under an edit. - /// Returns at once for any other row, and joins a lookup already running for the contact. When the lookup fails, the - /// row keeps its label. + /// running background refresh, which then drops its own result for it, so that refresh cannot change the row under an + /// edit. Returns at once for any other row, and joins a lookup already running for the contact. When the lookup fails, + /// the row keeps its label. func resolvePendingContactProfile(publicKey: String) async { await resolvePendingContactProfile(publicKey: publicKey, fetchRemoteProfile: remoteProfileLookup(on: .interactive)) } From 1c849ce0c56e43890ff6fd43958b802cceaa7827 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:42:39 +0200 Subject: [PATCH 49/79] test: check that a screen's contact lookup takes the contact over as it starts The background result for a taken-over contact was only released after the screen's lookup had failed, so a takeover made when that lookup finished would also have passed. Release it while the screen's lookup still runs as well, and check the row before and after that lookup fails. --- BitkitTests/ContactsManagerTests.swift | 60 ++++++++++++++++---------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 8632ffdea..886db9a18 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -839,29 +839,45 @@ final class ContactsManagerTests: XCTestCase { } func testBackgroundResultForAContactAScreenTookOverIsDropped() async throws { - let manager = ContactsManager() - let bulk = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) - await bulk.hold() - let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] - try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) - while await bulk.heldCount < 1 { - await Task.yield() - } - - let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) - await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } - - // The background lookup of the contact finds its profile only now, such as while a Save uploads an avatar. - await bulk.release() - await manager.waitForProfileRefreshForTesting() + // The background lookup of the contact finds its profile while the screen's own lookup still runs, or only once + // that lookup failed, such as while a Save uploads an avatar. + for arrivesWhileTheScreenLooksUp in [true, false] { + let message = "arrivesWhileTheScreenLooksUp: \(arrivesWhileTheScreenLooksUp)" + let manager = ContactsManager() + let bulk = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + while await bulk.heldCount < 1 { + await Task.yield() + } - let row = try XCTUnwrap(manager.contacts.first?.profile) - XCTAssertEqual(row.name, "Label only", "A background result for a contact a screen took over must not change its row") - XCTAssertEqual(row.bio, "") - XCTAssertNil(row.imageUrl) - try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { _ in throw profileTransportError }) - XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "Nor may the next load show it") - await manager.waitForProfileRefreshForTesting() + let interactive = HeldProfileLookups(profiles: [:]) + await interactive.hold() + let screenLookup = Task { + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + } + while await interactive.heldCount < 1 { + await Task.yield() + } + if arrivesWhileTheScreenLooksUp { + await bulk.release() + await manager.waitForProfileRefreshForTesting() + XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "Taken over when the screen's lookup started, \(message)") + } + await interactive.release() + await screenLookup.value + await bulk.release() + await manager.waitForProfileRefreshForTesting() + + let row = try XCTUnwrap(manager.contacts.first?.profile) + XCTAssertEqual(row.name, "Label only", "A background result for a contact a screen took over must not change its row, \(message)") + XCTAssertEqual(row.bio, "", message) + XCTAssertNil(row.imageUrl, message) + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { _ in throw profileTransportError }) + XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "Nor may the next load show it, \(message)") + await manager.waitForProfileRefreshForTesting() + } } func testInteractiveProfileLookupStartedBeforeResetIsIgnored() async throws { From 7ddd1573198421e3f643f1edfd0272c46d3cc560 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:58:14 +0200 Subject: [PATCH 50/79] test: merge near-duplicate contact lookup and load tests into case tables The transport retry tests, the cancelled-caller loadContactsIfNeeded tests and the found and failed interactive lane lookups each shared their setup and steps, so each group is now one test that loops over named cases. --- BitkitTests/ContactsManagerTests.swift | 201 ++++++++++--------------- 1 file changed, 82 insertions(+), 119 deletions(-) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 886db9a18..0b00e336d 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -188,48 +188,40 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(manager.isLoading) } - /// General Settings used to await this in its `.task`, so leaving mid-load surfaced the cancellation as an error toast. - func testCancelledLoadContactsIfNeededLeavesTheLoadToAnUncancelledCaller() async throws { - let record = contactRecord(key: "pubky" + String(repeating: "y", count: 52), name: "Contact") - let source = SuspendedContactRecords(records: [record]) - let manager = ContactsManager(contactRecords: { await source.load() }) - let screenLoad = Task { try await manager.loadContactsIfNeeded(for: "owner") } - while await !(source.isPaused) { + /// General Settings used to await `loadContactsIfNeeded` in its `.task`, so leaving mid-load surfaced the cancellation + /// as an error toast. Another caller still finishes the load whichever screen's load it waited on is cancelled. + func testLoadContactsIfNeededFinishesAfterTheLoadItWaitedOnIsCancelled() async throws { + let cases: [(name: String, cancelledLoadThrows: Bool, cancelledLoad: @MainActor (ContactsManager) async throws -> Void)] = [ + ("another loadContactsIfNeeded", true, { try await $0.loadContactsIfNeeded(for: "owner") }), + ("the Contacts screen's loadContacts", false, { try await $0.loadContacts(for: "owner") }), + ] + for testCase in cases { + let record = contactRecord(key: "pubky" + String(repeating: "y", count: 52), name: "Contact") + let source = SuspendedContactRecords(records: [record]) + let manager = ContactsManager(contactRecords: { await source.load() }) + let cancelledLoad = Task { try await testCase.cancelledLoad(manager) } + while await !(source.isPaused) { + await Task.yield() + } + let enable = Task { try await manager.loadContactsIfNeeded(for: "owner") } await Task.yield() - } - let enable = Task { try await manager.loadContactsIfNeeded(for: "owner") } - await Task.yield() - screenLoad.cancel() - await source.resume(with: [record]) - - let screenResult = await screenLoad.result - XCTAssertThrowsError(try screenResult.get()) { XCTAssertTrue($0 is CancellationError, "Expected cancellation, got \($0)") } - try await enable.value - XCTAssertTrue(manager.hasLoaded) - XCTAssertEqual(manager.contacts.map(\.publicKey), [record.publicKey]) - XCTAssertNil(manager.loadErrorMessage) - XCTAssertFalse(manager.isLoading) - } + cancelledLoad.cancel() + await source.resume(with: [record]) - func testLoadContactsIfNeededFinishesAfterTheContactsScreenLoadItWaitedOnIsCancelled() async throws { - let record = contactRecord(key: "pubky" + String(repeating: "y", count: 52), name: "Contact") - let source = SuspendedContactRecords(records: [record]) - let manager = ContactsManager(contactRecords: { await source.load() }) - let contactsScreenLoad = Task { try await manager.loadContacts(for: "owner") } - while await !(source.isPaused) { - await Task.yield() + let cancelledResult = await cancelledLoad.result + if testCase.cancelledLoadThrows { + XCTAssertThrowsError(try cancelledResult.get(), testCase.name) { + XCTAssertTrue($0 is CancellationError, "\(testCase.name): expected cancellation, got \($0)") + } + } else { + XCTAssertNoThrow(try cancelledResult.get(), testCase.name) + } + try await enable.value + XCTAssertTrue(manager.hasLoaded, testCase.name) + XCTAssertEqual(manager.contacts.map(\.publicKey), [record.publicKey], testCase.name) + XCTAssertNil(manager.loadErrorMessage, testCase.name) + XCTAssertFalse(manager.isLoading, testCase.name) } - let enable = Task { try await manager.loadContactsIfNeeded(for: "owner") } - await Task.yield() - contactsScreenLoad.cancel() - await source.resume(with: [record]) - - try await contactsScreenLoad.value - try await enable.value - XCTAssertTrue(manager.hasLoaded) - XCTAssertEqual(manager.contacts.map(\.publicKey), [record.publicKey]) - XCTAssertNil(manager.loadErrorMessage) - XCTAssertFalse(manager.isLoading) } private func contactRecord(key: String, name: String) -> ContactRecord { @@ -506,48 +498,28 @@ final class ContactsManagerTests: XCTestCase { } } - func testBulkContactProfileLookupDoesNotRetryTransportError() async throws { - let stub = ContactProfileFetchStub([.failure(profileTransportError), .success(makeProfile(publicKey: contactProfileKey))]) - - let profile = try await ContactsManager.resolveContactProfile( - publicKey: contactProfileKey, - includePlaceholder: true, - fetchRemoteProfile: { try await stub.fetch($0) } - ) - - XCTAssertEqual(profile.name, Bitkit.PubkyProfile.placeholder(publicKey: contactProfileKey).name) - let attempts = await stub.attempts - XCTAssertEqual(attempts, 1) - } - - func testUserInitiatedContactProfileLookupRetriesTransportErrorOnce() async throws { - let stub = ContactProfileFetchStub([.failure(profileTransportError), .success(makeProfile(publicKey: contactProfileKey))]) - - let profile = try await ContactsManager.resolveContactProfile( - publicKey: contactProfileKey, - includePlaceholder: true, - retryTransient: true, - fetchRemoteProfile: { try await stub.fetch($0) } - ) - - XCTAssertEqual(profile.name, "Alice") - let attempts = await stub.attempts - XCTAssertEqual(attempts, 2) - } - - func testUserInitiatedContactProfileLookupFallsBackToPlaceholderAfterOneRetry() async throws { - let stub = ContactProfileFetchStub([.failure(profileTransportError)]) + func testOnlyAUserInitiatedContactProfileLookupRetriesATransportErrorAndOnlyOnce() async throws { + let placeholderName = Bitkit.PubkyProfile.placeholder(publicKey: contactProfileKey).name + let found: Result = .success(makeProfile(publicKey: contactProfileKey)) + let cases: [(name: String, retryTransient: Bool, outcomes: [Result], expectedName: String, attempts: Int)] = [ + ("bulk lookup does not retry", false, [.failure(profileTransportError), found], placeholderName, 1), + ("user-initiated lookup retries once", true, [.failure(profileTransportError), found], "Alice", 2), + ("user-initiated lookup falls back to the placeholder after one retry", true, [.failure(profileTransportError)], placeholderName, 2), + ] + for testCase in cases { + let stub = ContactProfileFetchStub(testCase.outcomes) - let profile = try await ContactsManager.resolveContactProfile( - publicKey: contactProfileKey, - includePlaceholder: true, - retryTransient: true, - fetchRemoteProfile: { try await stub.fetch($0) } - ) + let profile = try await ContactsManager.resolveContactProfile( + publicKey: contactProfileKey, + includePlaceholder: true, + retryTransient: testCase.retryTransient, + fetchRemoteProfile: { try await stub.fetch($0) } + ) - XCTAssertEqual(profile.name, Bitkit.PubkyProfile.placeholder(publicKey: contactProfileKey).name) - let attempts = await stub.attempts - XCTAssertEqual(attempts, 2) + XCTAssertEqual(profile.name, testCase.expectedName, testCase.name) + let attempts = await stub.attempts + XCTAssertEqual(attempts, testCase.attempts, testCase.name) + } } func testContactProfileRetryStopsWhenCancelledDuringBackoff() async { @@ -734,49 +706,40 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(fetchedKeys, [contactProfileKey], "Reopening Contacts must not look the same contact up again") } - func testLabelOnlyContactLooksItsProfileUpOnTheInteractiveLane() async throws { - let manager = ContactsManager() - let bulk = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) - await bulk.hold() - let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] - try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) - let interactive = ContactProfileFetchStub([.success(publishedContactProfile)]) - - async let first: Void = manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } - async let second: Void = manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } - _ = await (first, second) - await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } - - let profile = try XCTUnwrap(manager.contacts.first?.profile) - XCTAssertEqual(profile.name, "Alice") - XCTAssertEqual(profile.bio, "Hello", "An edit made now must not save the label-only row over the bio") - XCTAssertEqual(profile.imageUrl, "pubky://alice/avatar") - XCTAssertEqual(profile.links.map(\.url), ["https://alice.example"]) - let attempts = await interactive.attempts - XCTAssertEqual(attempts, 1, "Concurrent callers share one lookup, and a resolved row is not looked up again") - await bulk.release() - await manager.waitForProfileRefreshForTesting() - } + func testOnlyALabelOnlyContactIsLookedUpOnTheInteractiveLaneAndOnlyOnce() async throws { + let labelOnly = Bitkit.PubkyProfile.forDisplay(publicKey: contactProfileKey, name: "Label only", imageUrl: nil) + let cases: [(name: String, outcome: Result, row: Bitkit.PubkyProfile, names: [String])] = [ + ("lookup finds the profile", .success(publishedContactProfile), publishedContactProfile, ["Alice", "Bob"]), + ("lookup fails", .failure(profileTransportError), labelOnly, ["Bob", "Label only"]), + ] + for testCase in cases { + let manager = ContactsManager() + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only"), contactRecord(key: unresolvedFollowKey, name: "Bob")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + let interactive = ContactProfileFetchStub([testCase.outcome]) - func testOnlyALabelOnlyContactIsLookedUpOnTheInteractiveLane() async throws { - let manager = ContactsManager() - let bulk = HeldProfileLookups(profiles: [:]) - await bulk.hold() - let records = [unprofiledRecord(key: contactProfileKey, label: "Label only"), contactRecord(key: unresolvedFollowKey, name: "Bob")] - try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) - let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) + await manager.resolvePendingContactProfile(publicKey: unresolvedFollowKey) { try await interactive.fetch($0) } + let storedAttempts = await interactive.attempts + XCTAssertEqual(storedAttempts, 0, "\(testCase.name): a stored profile is not looked up") - await manager.resolvePendingContactProfile(publicKey: unresolvedFollowKey) { try await interactive.fetch($0) } - let storedAttempts = await interactive.attempts - XCTAssertEqual(storedAttempts, 0, "A stored profile is not looked up") + async let first: Void = manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + async let second: Void = manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + _ = await (first, second) + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } - await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } - await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } - let attempts = await interactive.attempts - XCTAssertEqual(attempts, 1, "A contact whose own lookup failed is not looked up again") - XCTAssertEqual(manager.contacts.map(\.displayName), ["Bob", "Label only"]) - await bulk.release() - await manager.waitForProfileRefreshForTesting() + let attempts = await interactive.attempts + XCTAssertEqual(attempts, 1, "\(testCase.name): concurrent callers share one lookup, and the contact is not looked up again") + let row = try XCTUnwrap(manager.contacts.first { $0.publicKey == contactProfileKey }?.profile, testCase.name) + XCTAssertEqual(row.name, testCase.row.name, testCase.name) + XCTAssertEqual(row.bio, testCase.row.bio, "\(testCase.name): an edit made now keeps the bio the lookup found") + XCTAssertEqual(row.imageUrl, testCase.row.imageUrl, testCase.name) + XCTAssertEqual(row.links.map(\.url), testCase.row.links.map(\.url), testCase.name) + XCTAssertEqual(manager.contacts.map(\.displayName), testCase.names, testCase.name) + await bulk.release() + await manager.waitForProfileRefreshForTesting() + } } func testEditSavesWithoutWaitingForTheQueuedBackgroundLookupWhenTheContactsOwnLookupFails() async throws { From 11d9f76df8451381cdd561f1d0d1e2290f4e319c Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:58:14 +0200 Subject: [PATCH 51/79] test: check every bulk public read kind in one lock and read slot test The profile lookup, receiver discovery, payment request paths and private path selection tests ran the same steps, so they are now cases of one test that also checks the profile lookup leaves the SDK lock free. --- BitkitTests/PaykitPublicReadLaneTests.swift | 152 +++++++------------- 1 file changed, 49 insertions(+), 103 deletions(-) diff --git a/BitkitTests/PaykitPublicReadLaneTests.swift b/BitkitTests/PaykitPublicReadLaneTests.swift index ec62fc5b8..68773bed4 100644 --- a/BitkitTests/PaykitPublicReadLaneTests.swift +++ b/BitkitTests/PaykitPublicReadLaneTests.swift @@ -3,83 +3,59 @@ import Paykit import XCTest final class PaykitPublicReadLaneTests: XCTestCase { - func testBulkProfileLookupsLeaveReadSlotsForInteractiveFetches() async throws { - let sdk = PublicReadLaneSdk(noPointer: .init()) - let service = PaykitSdkService(sdkFactory: { sdk }) - let lookups = (0 ..< 6).map { index in - Task { - try await service.resolveContactProfile(publicKey: "contact\(index)", allowPubkyProfileFallback: true, priority: .bulk) + /// Five bulk reads of each kind start, each blocked on its first network read. Only four may run, and a contact record + /// read under the SDK lock and an interactive fetch must both finish meanwhile. + func testBulkReadsLeaveTheSdkLockAndReadSlotsFree() async throws { + let cases: [(name: String, read: @Sendable (PaykitSdkService, String) async throws -> Void)] = [ + ("profile lookup", { service, publicKey in + let resolution = try await service.resolveContactProfile(publicKey: publicKey, allowPubkyProfileFallback: true, priority: .bulk) + XCTAssertNil(resolution, "profile lookup") + }), + ("receiver discovery", { service, publicKey in + let paths = try await service.discoverRelevantReceiverPaths(publicKey: publicKey, priority: .bulk) + XCTAssertEqual(paths, [PaykitReceiverPath.wallet, PaykitReceiverPath.server], "receiver discovery") + }), + ("payment request receiver paths", { service, publicKey in + let paths = try await service.paymentRequestReceiverPaths(publicKey: publicKey, priority: .bulk) + XCTAssertEqual(paths, [PaykitReceiverPath.server], "payment request receiver paths") + }), + ("private receiver path selection", { service, publicKey in + let selection = try await service.privateReceiverPathSelection(publicKey: publicKey, savedReceiverPaths: [PaykitReceiverPath.server]) + XCTAssertEqual(selection.linkableReceiverPaths, [PaykitReceiverPath.server], "private receiver path selection") + XCTAssertEqual(selection.publishableReceiverPaths, [PaykitReceiverPath.server], "private receiver path selection") + XCTAssertEqual(selection.cleanupProtectedReceiverPaths, [], "private receiver path selection") + XCTAssertNil(selection.error, "private receiver path selection") + }), + ] + for testCase in cases { + let sdk = PublicReadLaneSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let reads = (0 ..< 5).map { index in + Task { try await testCase.read(service, "contact\(index)") } } - } - try await sdk.log.waitForEntries(count: 4) - try await Task.sleep(for: .milliseconds(50)) - let lookupsInFlight = await sdk.log.entries.count - XCTAssertEqual(lookupsInFlight, 4, "Bulk lookups may hold only four of the six read slots") - - let fetched = expectation(description: "Interactive fetch finished") - let fetch = Task { - _ = try await service.fetchFile(uri: "pubky://avatar", maxBytes: 10) - fetched.fulfill() - } - await fulfillment(of: [fetched], timeout: 2) - - await sdk.gate.open() - try await fetch.value - for lookup in lookups { - _ = try await lookup.value - } - } - - func testReceiverDiscoveryDoesNotHoldTheSdkLock() async throws { - let sdk = PublicReadLaneSdk(noPointer: .init()) - let service = PaykitSdkService(sdkFactory: { sdk }) - let discoveries = (0 ..< 5).map { index in - Task { try await service.discoverRelevantReceiverPaths(publicKey: "contact\(index)", priority: .bulk) } - } - - try await assertBulkReadsLeaveTheLockAndReadSlotsFree(sdk: sdk, service: service) - - for discovery in discoveries { - let paths = try await discovery.value - XCTAssertEqual(paths, [PaykitReceiverPath.wallet, PaykitReceiverPath.server]) - } - } - - func testPaymentRequestReceiverPathsDoesNotHoldTheSdkLock() async throws { - let sdk = PublicReadLaneSdk(noPointer: .init()) - let service = PaykitSdkService(sdkFactory: { sdk }) - let checks = (0 ..< 5).map { index in - Task { try await service.paymentRequestReceiverPaths(publicKey: "contact\(index)", priority: .bulk) } - } - - try await assertBulkReadsLeaveTheLockAndReadSlotsFree(sdk: sdk, service: service) + try await sdk.log.waitForEntries(count: 4) + try await Task.sleep(for: .milliseconds(50)) + let readsInFlight = await sdk.log.entries.count + XCTAssertEqual(readsInFlight, 4, "\(testCase.name): only four bulk reads may run at once") - for check in checks { - let paths = try await check.value - XCTAssertEqual(paths, [PaykitReceiverPath.server]) - } - } - - func testPrivateReceiverPathSelectionDoesNotHoldTheSdkLock() async throws { - let sdk = PublicReadLaneSdk(noPointer: .init()) - let service = PaykitSdkService(sdkFactory: { sdk }) - let selections = (0 ..< 5).map { index in - Task { - try await service.privateReceiverPathSelection( - publicKey: "contact\(index)", - savedReceiverPaths: [PaykitReceiverPath.server] - ) + let lockedReadFinished = expectation(description: "\(testCase.name): contact record read under the SDK lock finished") + let lockedRead = Task { + _ = try await service.contactRecords() + lockedReadFinished.fulfill() } - } - - try await assertBulkReadsLeaveTheLockAndReadSlotsFree(sdk: sdk, service: service) + let fetched = expectation(description: "\(testCase.name): interactive fetch finished") + let fetch = Task { + _ = try await service.fetchFile(uri: "pubky://avatar", maxBytes: 10) + fetched.fulfill() + } + await fulfillment(of: [lockedReadFinished, fetched], timeout: 2) - for selection in selections { - let result = try await selection.value - XCTAssertEqual(result.linkableReceiverPaths, [PaykitReceiverPath.server]) - XCTAssertEqual(result.publishableReceiverPaths, [PaykitReceiverPath.server]) - XCTAssertEqual(result.cleanupProtectedReceiverPaths, []) - XCTAssertNil(result.error) + await sdk.gate.open() + try await lockedRead.value + try await fetch.value + for read in reads { + try await read.value + } } } @@ -207,36 +183,6 @@ final class PaykitPublicReadLaneTests: XCTestCase { XCTFail("\(message): got \(error)", file: file, line: line) } } - - /// Expects five bulk reads to have been started, each blocked on its first network read. Four may run, a contact - /// record read under the SDK lock and an interactive fetch must both finish meanwhile, then the gate opens. - private func assertBulkReadsLeaveTheLockAndReadSlotsFree( - sdk: PublicReadLaneSdk, - service: PaykitSdkService, - file: StaticString = #filePath, - line: UInt = #line - ) async throws { - try await sdk.log.waitForEntries(count: 4) - try await Task.sleep(for: .milliseconds(50)) - let readsInFlight = await sdk.log.entries.count - XCTAssertEqual(readsInFlight, 4, "Only four bulk reads may run at once", file: file, line: line) - - let lockedReadFinished = expectation(description: "Contact record read under the SDK lock finished") - let lockedRead = Task { - _ = try await service.contactRecords() - lockedReadFinished.fulfill() - } - let fetched = expectation(description: "Interactive fetch finished") - let fetch = Task { - _ = try await service.fetchFile(uri: "pubky://avatar", maxBytes: 10) - fetched.fulfill() - } - await fulfillment(of: [lockedReadFinished, fetched], timeout: 2) - - await sdk.gate.open() - try await lockedRead.value - try await fetch.value - } } /// Records the reads that have started, in order. From 88bac3a1a15a0a8bc4585de8fb86954d1cbb81a6 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:58:14 +0200 Subject: [PATCH 52/79] test: check both cancelled queued reads in one read limiter test The cancelled bulk read waiting for a read slot ran the same steps as the cancelled queued read, so both are now cases of one test. --- BitkitTests/PaykitSdkReadLimiterTests.swift | 104 +++++++------------- 1 file changed, 38 insertions(+), 66 deletions(-) diff --git a/BitkitTests/PaykitSdkReadLimiterTests.swift b/BitkitTests/PaykitSdkReadLimiterTests.swift index d2faa4993..9ea1a351e 100644 --- a/BitkitTests/PaykitSdkReadLimiterTests.swift +++ b/BitkitTests/PaykitSdkReadLimiterTests.swift @@ -12,6 +12,8 @@ final class PaykitSdkReadLimiterTests: XCTestCase { private struct ReadFailure: Error {} + private typealias SlotRun = @Sendable (@Sendable () async -> Void) async throws -> Void + private struct Gate { private let signal = AsyncStream.makeStream() @@ -113,40 +115,49 @@ final class PaykitSdkReadLimiterTests: XCTestCase { XCTAssertEqual(events, ["holder", "a", "b", "c"]) } + /// A cancelled read throws while the slot it queues for is still held and takes no slot, so the read queued behind it + /// runs next. A bulk read holding its bulk slot while it waits for a read slot gives that bulk slot up. func testCancelledQueuedReadLeavesQueueWithoutTakingASlot() async throws { let limiter = PaykitSdkReadLimiter(maxConcurrent: 1) - let recorder = Recorder() - let holderGate = Gate() - let holder = startGatedRead("holder", on: limiter, recorder: recorder, gate: holderGate) - try await waitForEvents(recorder, count: 1) - - let lookup = Task { - try await limiter.withSlot { - await recorder.record("lookup") + let slots = PaykitPublicReadSlots(readCap: 1, bulkCap: 1) + let cases: [(name: String, hold: SlotRun, queue: SlotRun)] = [ + ("queued read", { try await limiter.withSlot($0) }, { try await limiter.withSlot($0) }), + ("bulk read waiting for a read slot", { try await slots.withSlot(.interactive, $0) }, { try await slots.withSlot(.bulk, $0) }), + ] + for testCase in cases { + let recorder = Recorder() + let holderGate = Gate() + let holder = Task { + try await testCase.hold { + await recorder.record("holder") + await holderGate.wait() + } } - } - try await Task.sleep(for: .milliseconds(50)) - let payment = Task { - try await limiter.withSlot { - await recorder.record("payment") + try await waitForEvents(recorder, count: 1) + + let abandonedThrew = expectation(description: "\(testCase.name): cancelled read threw while the slot was still held") + let abandoned = Task { + do { + try await testCase.queue { await recorder.record("abandoned") } + } catch is CancellationError { + abandonedThrew.fulfill() + } } - } - try await Task.sleep(for: .milliseconds(50)) + try await Task.sleep(for: .milliseconds(50)) + let next = Task { try await testCase.queue { await recorder.record("next") } } + try await Task.sleep(for: .milliseconds(50)) - lookup.cancel() - do { - try await lookup.value - XCTFail("Expected the cancelled lookup to throw while the slot is still held") - } catch is CancellationError {} + abandoned.cancel() + await fulfillment(of: [abandonedThrew], timeout: 2) - holderGate.open() - try await holder.value - try await payment.value - try await limiter.withSlot { - await recorder.record("after") + holderGate.open() + try await abandoned.value + try await holder.value + try await next.value + try await testCase.queue { await recorder.record("after") } + let events = await recorder.events + XCTAssertEqual(events, ["holder", "next", "after"], testCase.name) } - let events = await recorder.events - XCTAssertEqual(events, ["holder", "payment", "after"]) } func testAlreadyCancelledReadDoesNotRunOrTakeASlot() async throws { @@ -217,45 +228,6 @@ final class PaykitSdkReadLimiterTests: XCTestCase { XCTAssertEqual(finalEvents.last, "bulk4") } - func testCancelledBulkReadWaitingForAReadSlotGivesUpItsBulkSlot() async throws { - let slots = PaykitPublicReadSlots(readCap: 1, bulkCap: 1) - let recorder = Recorder() - let holderGate = Gate() - let holder = startGatedRead("holder", on: slots, priority: .interactive, recorder: recorder, gate: holderGate) - try await waitForEvents(recorder, count: 1) - - let abandonedThrew = expectation(description: "Cancelled bulk read threw while the read slot was still held") - let abandoned = Task { - do { - try await slots.withSlot(.bulk) { - await recorder.record("abandoned") - } - } catch is CancellationError { - abandonedThrew.fulfill() - } - } - try await Task.sleep(for: .milliseconds(50)) - let next = Task { - try await slots.withSlot(.bulk) { - await recorder.record("next") - } - } - try await Task.sleep(for: .milliseconds(50)) - - abandoned.cancel() - await fulfillment(of: [abandonedThrew], timeout: 2) - - holderGate.open() - try await abandoned.value - try await holder.value - try await next.value - try await slots.withSlot(.bulk) { - await recorder.record("after") - } - let events = await recorder.events - XCTAssertEqual(events, ["holder", "next", "after"]) - } - func testFailingReadReleasesItsSlotToTheNextWaiter() async throws { let limiter = PaykitSdkReadLimiter(maxConcurrent: 1) let recorder = Recorder() From c51004f16b452ca863ce825f5112f189a84b0d7c Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:58:14 +0200 Subject: [PATCH 53/79] test: table the mirrored Ring row profiles and the row spinner checks --- BitkitTests/PubkyChoiceViewTests.swift | 56 +++++++++++--------------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/BitkitTests/PubkyChoiceViewTests.swift b/BitkitTests/PubkyChoiceViewTests.swift index e906d1ee3..46372a2b1 100644 --- a/BitkitTests/PubkyChoiceViewTests.swift +++ b/BitkitTests/PubkyChoiceViewTests.swift @@ -15,44 +15,34 @@ final class PubkyChoiceViewTests: XCTestCase { XCTAssertTrue(PubkyChoiceView.showsCreateCard(hasRingIdentities: false)) } - func testMirroredRingProfilesEqualTheFoundProfilesWhenNotAdopting() { - let shown = ["alice": profile("alice", name: "Alice"), "bob": profile("bob", name: "Bob")] - let found = ["alice": profile("alice", name: "Alice Renamed")] - - let mirrored = PubkyChoiceView.mirroredRingProfiles(shown, found: found, isAdopting: false) - - XCTAssertEqual(mirrored.mapValues(\.name), ["alice": "Alice Renamed"], "A row whose lookup now finds nothing drops its old profile") - } - - func testMirroredRingProfilesOnlyAddWhileAdopting() { + func testMirroredRingProfilesEqualTheFoundProfilesButOnlyAddWhileAdopting() { let shown = ["alice": profile("alice", name: "Alice"), "bob": profile("bob", name: "Bob")] let found = ["alice": profile("alice", name: "Alice Renamed"), "carol": profile("carol", name: "Carol")] - - let cleared = PubkyChoiceView.mirroredRingProfiles(shown, found: [:], isAdopting: true) - let merged = PubkyChoiceView.mirroredRingProfiles(shown, found: found, isAdopting: true) - - XCTAssertEqual(cleared.mapValues(\.name), ["alice": "Alice", "bob": "Bob"], "Rows keep their profiles while adopting clears the cache") - XCTAssertEqual(merged.mapValues(\.name), ["alice": "Alice Renamed", "bob": "Bob", "carol": "Carol"]) + let cases: [(name: String, found: [String: PubkyProfile], isAdopting: Bool, expected: [String: String])] = [ + ("a row whose lookup now finds nothing drops its old profile", found, false, ["alice": "Alice Renamed", "carol": "Carol"]), + ("rows keep their profiles while adopting clears the cache", [:], true, ["alice": "Alice", "bob": "Bob"]), + ("adopting only adds", found, true, ["alice": "Alice Renamed", "bob": "Bob", "carol": "Carol"]), + ] + for testCase in cases { + let mirrored = PubkyChoiceView.mirroredRingProfiles(shown, found: testCase.found, isAdopting: testCase.isAdopting) + XCTAssertEqual(mirrored.mapValues(\.name), testCase.expected, testCase.name) + } } + /// A finished lookup brings back the avatar, a found profile replaces the spinner, and the adopting row shows only its + /// key-icon spinner. func testRingLookupShowsOnlyForARowStillLookingUpWithoutAProfileThatIsNotBeingAdopted() { - XCTAssertTrue(PubkyChoiceView.showsRingLookup(isLookingUp: true, hasProfile: false, isAdoptingRow: false)) - XCTAssertFalse( - PubkyChoiceView.showsRingLookup(isLookingUp: false, hasProfile: false, isAdoptingRow: false), - "A finished lookup brings back the avatar" - ) - XCTAssertFalse( - PubkyChoiceView.showsRingLookup(isLookingUp: true, hasProfile: true, isAdoptingRow: false), - "A found profile replaces the spinner" - ) - XCTAssertFalse( - PubkyChoiceView.showsRingLookup(isLookingUp: true, hasProfile: false, isAdoptingRow: true), - "The adopting row shows only its key-icon spinner" - ) - XCTAssertFalse(PubkyChoiceView.showsRingLookup(isLookingUp: true, hasProfile: true, isAdoptingRow: true)) - XCTAssertFalse(PubkyChoiceView.showsRingLookup(isLookingUp: false, hasProfile: true, isAdoptingRow: false)) - XCTAssertFalse(PubkyChoiceView.showsRingLookup(isLookingUp: false, hasProfile: false, isAdoptingRow: true)) - XCTAssertFalse(PubkyChoiceView.showsRingLookup(isLookingUp: false, hasProfile: true, isAdoptingRow: true)) + for isLookingUp in [true, false] { + for hasProfile in [true, false] { + for isAdoptingRow in [true, false] { + XCTAssertEqual( + PubkyChoiceView.showsRingLookup(isLookingUp: isLookingUp, hasProfile: hasProfile, isAdoptingRow: isAdoptingRow), + isLookingUp && !hasProfile && !isAdoptingRow, + "isLookingUp: \(isLookingUp), hasProfile: \(hasProfile), isAdoptingRow: \(isAdoptingRow)" + ) + } + } + } } private func profile(_ publicKey: String, name: String) -> PubkyProfile { From 5e5e18ad511d2fc425f6f6c0af2d82aa62423a50 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 21:58:14 +0200 Subject: [PATCH 54/79] test: merge Ring adoption and sign-out tests that differ only by case The reused row profile refresh outcomes, the adopted key forms, the found and missed tapped row lookups and sign-out against recovery while signed out each shared their steps, so each group is now one test over named cases. --- BitkitTests/PubkyProfileManagerTests.swift | 337 +++++++++------------ 1 file changed, 137 insertions(+), 200 deletions(-) diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index becf50bc9..fcadc59bb 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1312,171 +1312,127 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertTrue(manager.ringIdentityLookupsInFlight.isEmpty) } + /// Automatic recovery also clears the authenticated state while nothing is signed in, which must keep the choice rows. @MainActor - func testClearingAuthenticatedStateDropsRingIdentityProfilesAndLookups() async { - await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) - await stub.setHoldsRequests(true) - let inFlight = Task { await manager.loadRingIdentityProfiles([bareRingKeyC]) } - await stub.waitForRequests(3) - - XCTAssertEqual(manager.ringIdentityLookupsInFlight, [ringKeyC]) - manager.publicKey = ringKeyA - manager.clearAuthenticatedStateForTesting() - XCTAssertTrue(manager.ringIdentityLookupsInFlight.isEmpty, "The reset drops a row lookup before its task ends") - await stub.setProfile(makeProfile(publicKey: ringKeyC, name: "Carol"), for: ringKeyC) - await stub.release(request: 2) - await inFlight.value - XCTAssertTrue(manager.ringIdentityProfiles.isEmpty) - XCTAssertTrue(manager.ringIdentityLookupsInFlight.isEmpty) - - await stub.setHoldsRequests(false) - await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) - let requests = await stub.requests - XCTAssertEqual(requests.count, 5, "The miss is forgotten along with the found profile") - } - } - - @MainActor - func testAutomaticRecoveryWhileSignedOutKeepsRingIdentityProfilesAndLookups() async { + func testSignOutDropsRingIdentityProfilesAndLookupsButRecoveryWhileSignedOutKeepsThem() async { let savedReference = AdoptedPubkyReference.current defer { AdoptedPubkyReference.current = savedReference } - AdoptedPubkyReference.current = nil - await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) - await stub.setHoldsRequests(true) - let inFlight = Task { await manager.loadRingIdentityProfiles([bareRingKeyC]) } - await stub.waitForRequests(3) - - for result in [PubkyProfileManager.SessionInitializationResult.noSession, .restorationFailed] { - await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { result } + let cases: [(name: String, keepsRows: Bool, clear: @MainActor (PubkyProfileManager) async -> Void)] = [ + ("sign-out", false, { manager in + manager.publicKey = ringKeyA + manager.clearAuthenticatedStateForTesting() + }), + ("automatic recovery while signed out", true, { manager in + for result in [PubkyProfileManager.SessionInitializationResult.noSession, .restorationFailed] { + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { result } + } + }), + ] + for testCase in cases { + AdoptedPubkyReference.current = nil + await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + await stub.setHoldsRequests(true) + let inFlight = Task { await manager.loadRingIdentityProfiles([bareRingKeyC]) } + await stub.waitForRequests(3) + XCTAssertEqual(manager.ringIdentityLookupsInFlight, [ringKeyC], testCase.name) + + await testCase.clear(manager) + XCTAssertNil(manager.publicKey, testCase.name) + XCTAssertEqual( + manager.ringIdentityLookupsInFlight, + testCase.keepsRows ? [ringKeyC] : [], + "\(testCase.name): a dropped row lookup leaves before its task ends" + ) + await stub.setProfile(makeProfile(publicKey: ringKeyC, name: "Carol"), for: ringKeyC) + await stub.release(request: 2) + await inFlight.value + let cancelledRequests = await stub.cancelledRequests + XCTAssertEqual(cancelledRequests, testCase.keepsRows ? [] : [2], "\(testCase.name): only a dropped row lookup stops") + XCTAssertEqual( + manager.ringIdentityProfiles.mapValues(\.name), + testCase.keepsRows ? [ringKeyA: "Alice", ringKeyC: "Carol"] : [:], + testCase.name + ) + XCTAssertEqual(manager.ringIdentityLookupsInFlight, [], testCase.name) + + await stub.setHoldsRequests(false) + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB, bareRingKeyC]) + let requests = await stub.requests + XCTAssertEqual( + requests.count, + testCase.keepsRows ? 3 : 6, + "\(testCase.name): found profiles and misses are forgotten together or kept together" + ) } - XCTAssertNil(manager.publicKey) - - await stub.setProfile(makeProfile(publicKey: ringKeyC, name: "Carol"), for: ringKeyC) - await stub.release(request: 2) - await inFlight.value - let cancelledRequests = await stub.cancelledRequests - XCTAssertTrue(cancelledRequests.isEmpty, "Recovery while signed out stops no row lookup") - XCTAssertEqual(manager.ringIdentityProfiles[ringKeyA]?.name, "Alice") - XCTAssertEqual(manager.ringIdentityProfiles[ringKeyC]?.name, "Carol") - - await stub.setHoldsRequests(false) - await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB, bareRingKeyC]) - let requests = await stub.requests - XCTAssertEqual(requests.count, 3, "Neither the found profiles nor the miss are looked up again") - } - } - - @MainActor - func testRingAdoptionReusesTheFoundRowProfileAndClearsTheRows() async throws { - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) - // Held from here on, so adoption only returns if it reads nothing remote itself. - await stub.setHoldsRequests(true) - - let adopted = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) - - XCTAssertEqual(adopted?.name, "Alice") - XCTAssertEqual(manager.profile?.name, "Alice") - XCTAssertEqual(manager.cachedName, "Alice") - XCTAssertEqual(manager.publicKey, ringKeyA) - XCTAssertEqual(manager.authState, .authenticated) - XCTAssertFalse(manager.isProfileSetupPending) - XCTAssertTrue(manager.ringIdentityProfiles.isEmpty) - - await stub.waitForRequests(3) - let requests = await stub.requests - XCTAssertEqual(requests.count, 3, "Adoption reuses the row's profile; only the background refresh reads it again") - XCTAssertEqual(requests.last, ringKeyA) - await stub.release(request: 2) - await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } - } - } - - @MainActor - func testRingAdoptionReusesTheRowProfileWhateverFormTheAdoptedKeyTakes() async throws { - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - await manager.loadRingIdentityProfiles([bareRingKeyA]) - await stub.setHoldsRequests(true) - - let adopted = try await manager.completeRingAdoptionForTesting(publicKey: bareRingKeyA) - - XCTAssertEqual(adopted?.name, "Alice", "Rows are cached under the normalized pubky") - XCTAssertFalse(manager.isProfileSetupPending) - await stub.waitForRequests(2) - await stub.release(request: 1) - await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } - let requests = await stub.requests - XCTAssertEqual(requests, [ringKeyA, bareRingKeyA], "Only the background refresh reads again") - } - } - - @MainActor - func testRingAdoptionRefreshUpdatesAReusedRowProfile() async throws { - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - await manager.loadRingIdentityProfiles([bareRingKeyA]) - await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice Renamed"), for: ringKeyA) - - _ = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) - await stub.waitForRequests(2) - await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } - - XCTAssertEqual(manager.profile?.name, "Alice Renamed") - XCTAssertEqual(manager.cachedName, "Alice Renamed") - XCTAssertFalse(manager.isProfileSetupPending) } } @MainActor - func testRingAdoptionStartsProfileSetupWhenTheReusedRowProfileWasRemoved() async throws { - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - await manager.loadRingIdentityProfiles([bareRingKeyA]) - await stub.setProfile(nil, for: ringKeyA) - - let adopted = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) - XCTAssertEqual(adopted?.name, "Alice", "Adoption reuses the row profile found earlier") - await stub.waitForRequests(2) - await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } - - XCTAssertNil(manager.profile, "A definitive not-found undoes the reused profile") - XCTAssertNil(manager.cachedName) - XCTAssertNil(UserDefaults.standard.string(forKey: "pubky_profile_name")) - XCTAssertNil(UserDefaults.standard.string(forKey: "pubky_profile_owner")) - XCTAssertTrue(manager.isProfileSetupPending) - XCTAssertTrue(UserDefaults.standard.bool(forKey: "pubky_profile_setup_pending")) - XCTAssertEqual(manager.publicKey, ringKeyA) - XCTAssertEqual(manager.authState, .authenticated) + func testRingAdoptionReusesTheFoundRowProfileWhateverFormTheAdoptedKeyTakesAndClearsTheRows() async throws { + // Rows are cached under the normalized pubky. + for (name, adoptedKey) in [("prefixed key", ringKeyA), ("bare key", bareRingKeyA)] { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) + // Held from here on, so adoption only returns if it reads nothing remote itself. + await stub.setHoldsRequests(true) + + let adopted = try await manager.completeRingAdoptionForTesting(publicKey: adoptedKey) + + XCTAssertEqual(adopted?.name, "Alice", name) + XCTAssertEqual(manager.profile?.name, "Alice", name) + XCTAssertEqual(manager.cachedName, "Alice", name) + XCTAssertEqual(manager.publicKey, adoptedKey, name) + XCTAssertEqual(manager.authState, .authenticated, name) + XCTAssertFalse(manager.isProfileSetupPending, name) + XCTAssertTrue(manager.ringIdentityProfiles.isEmpty, name) + + await stub.waitForRequests(3) + let requests = await stub.requests + XCTAssertEqual(Set(requests.prefix(2)), [ringKeyA, ringKeyB], name) + XCTAssertEqual(requests.dropFirst(2), [adoptedKey], "\(name): only the background refresh reads the row's profile again") + await stub.release(request: 2) + await waitUntil("\(name): the background refresh finishes") { !manager.isLoadingProfile } + } } } + /// Only a definitive not-found undoes the reused row profile and starts profile setup; an offline refresh keeps it. @MainActor - func testRingAdoptionKeepsTheReusedRowProfileWhenItsRefreshFails() async throws { - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - await manager.loadRingIdentityProfiles([bareRingKeyA]) - await stub.makeUnreachable(ringKeyA) - - _ = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) - await stub.waitForRequests(2) - await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } + func testRingAdoptionRefreshOfAReusedRowProfile() async throws { + let renamed = makeProfile(publicKey: ringKeyA, name: "Alice Renamed") + let cases: [(name: String, change: (RemoteProfileStub) async -> Void, expectedName: String?)] = [ + ("renamed", { await $0.setProfile(renamed, for: ringKeyA) }, "Alice Renamed"), + ("removed", { await $0.setProfile(nil, for: ringKeyA) }, nil), + ("offline", { await $0.makeUnreachable(ringKeyA) }, "Alice"), + ] + for testCase in cases { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + await manager.loadRingIdentityProfiles([bareRingKeyA]) + await testCase.change(stub) - XCTAssertEqual(manager.profile?.name, "Alice", "An offline refresh keeps the adopted row profile") - XCTAssertEqual(manager.cachedName, "Alice") - XCTAssertFalse(manager.isProfileSetupPending) + let adopted = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + XCTAssertEqual(adopted?.name, "Alice", "\(testCase.name): adoption reuses the row profile found earlier") + await stub.waitForRequests(2) + await waitUntil("\(testCase.name): the background refresh finishes") { !manager.isLoadingProfile } + + let defaults = UserDefaults.standard + let setupPending = testCase.expectedName == nil + XCTAssertEqual(manager.profile?.name, testCase.expectedName, testCase.name) + XCTAssertEqual(manager.cachedName, testCase.expectedName, testCase.name) + XCTAssertEqual(defaults.string(forKey: "pubky_profile_name"), testCase.expectedName, testCase.name) + XCTAssertEqual(defaults.string(forKey: "pubky_profile_owner"), setupPending ? nil : ringKeyA, testCase.name) + XCTAssertEqual(manager.isProfileSetupPending, setupPending, testCase.name) + XCTAssertEqual(defaults.bool(forKey: "pubky_profile_setup_pending"), setupPending, testCase.name) + XCTAssertEqual(manager.publicKey, ringKeyA, testCase.name) + XCTAssertEqual(manager.authState, .authenticated, testCase.name) + } } } @@ -1628,55 +1584,36 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testRingAdoptionWaitsForTheTappedRowLookupInsteadOfFetchingAgain() async throws { - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(holdsRequests: true) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } - await stub.waitForRequests(1) - - let adoption = Task { try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) } - await waitUntil("adoption reaches the row lookup") { manager.publicKey == ringKeyA } - await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice"), for: ringKeyA) - await stub.release(request: 0) - await rows.value - // A fetch of adoption's own would answer with this instead. - await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice Refetched"), for: ringKeyA) - - let adopted = try await adoption.value - XCTAssertEqual(adopted?.name, "Alice", "Adoption reuses the tapped row's lookup that was still in flight") - XCTAssertFalse(manager.isProfileSetupPending) - - await stub.waitForRequests(2) - await stub.release(request: 1) - await waitUntil("the background refresh finishes") { !manager.isLoadingProfile } - let requests = await stub.requests - XCTAssertEqual(requests, [ringKeyA, ringKeyA], "Only the background refresh reads again") - } - } - - @MainActor - func testRingAdoptionFetchesWhenTheTappedRowLookupInFlightMisses() async throws { - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(holdsRequests: true) - let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) - let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } - await stub.waitForRequests(1) - - let adoption = Task { try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) } - await waitUntil("adoption reaches the row lookup") { manager.publicKey == ringKeyA } - await stub.release(request: 0) - await rows.value - // A row miss can mean offline, so the fresh fetch still decides profile setup. - await stub.waitForRequests(2) - await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice"), for: ringKeyA) - await stub.release(request: 1) + func testRingAdoptionWaitsForTheTappedRowLookupAndFetchesOnlyWhenItMisses() async throws { + for rowLookupFinds in [true, false] { + let name = "rowLookupFinds: \(rowLookupFinds)" + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(holdsRequests: true) + let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) + let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } + await stub.waitForRequests(1) - let adopted = try await adoption.value - XCTAssertEqual(adopted?.name, "Alice") - XCTAssertFalse(manager.isProfileSetupPending) - let requests = await stub.requests - XCTAssertEqual(requests, [ringKeyA, ringKeyA]) + let adoption = Task { try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) } + await waitUntil("\(name): adoption reaches the row lookup") { manager.publicKey == ringKeyA } + if rowLookupFinds { + await stub.setProfile(makeProfile(publicKey: ringKeyA, name: "Alice"), for: ringKeyA) + } + await stub.release(request: 0) + await rows.value + // Adoption reuses a profile the row's lookup found, so a fetch of its own would answer "Alice Refetched". A + // row miss can mean offline, so then the fresh fetch still decides profile setup. + await stub.setProfile(makeProfile(publicKey: ringKeyA, name: rowLookupFinds ? "Alice Refetched" : "Alice"), for: ringKeyA) + // The second request is the background refresh of a reused profile, or adoption's own fetch after a miss. + await stub.waitForRequests(2) + await stub.release(request: 1) + + let adopted = try await adoption.value + XCTAssertEqual(adopted?.name, "Alice", name) + XCTAssertFalse(manager.isProfileSetupPending, name) + await waitUntil("\(name): the background refresh finishes") { !manager.isLoadingProfile } + let requests = await stub.requests + XCTAssertEqual(requests, [ringKeyA, ringKeyA], name) + } } } From 8ef37a412438a0efae12e7a337dacaeec80da53f Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 1 Oct 2026 22:22:00 +0200 Subject: [PATCH 55/79] test: restore checks the merged case tables lost and name each failing case - Ring adoption that joins the tapped row's lookup in flight must return while the reused profile's refresh is still held, as before the merge. - Every bulk read lane case starts six reads again, so queued bulk reads that held read slots would take all six and starve the interactive fetch. - The bare-key adoption checks its lookups after the background refresh has finished, not before it. - The bulk contact lookup test stands on its own again and leaves retryTransient out, so it pins the default that bulk and screen lookups rely on. - In the case tables, a thrown error, a failed task or a missing row now fails only its own case, by name, and the next case still runs. The remote profile stub names the case in its own failures. --- BitkitTests/ContactsManagerTests.swift | 53 ++++++++++++------- BitkitTests/PaykitPublicReadLaneTests.swift | 14 ++--- BitkitTests/PaykitSdkReadLimiterTests.swift | 13 +++-- BitkitTests/PubkyProfileManagerTests.swift | 57 +++++++++++++++------ 4 files changed, 91 insertions(+), 46 deletions(-) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 0b00e336d..cf57d9dcb 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -216,7 +216,8 @@ final class ContactsManagerTests: XCTestCase { } else { XCTAssertNoThrow(try cancelledResult.get(), testCase.name) } - try await enable.value + let enableResult = await enable.result + XCTAssertNoThrow(try enableResult.get(), testCase.name) XCTAssertTrue(manager.hasLoaded, testCase.name) XCTAssertEqual(manager.contacts.map(\.publicKey), [record.publicKey], testCase.name) XCTAssertNil(manager.loadErrorMessage, testCase.name) @@ -498,27 +499,41 @@ final class ContactsManagerTests: XCTestCase { } } - func testOnlyAUserInitiatedContactProfileLookupRetriesATransportErrorAndOnlyOnce() async throws { + func testBulkContactProfileLookupDoesNotRetryTransportError() async throws { + let stub = ContactProfileFetchStub([.failure(profileTransportError), .success(makeProfile(publicKey: contactProfileKey))]) + + // Bulk and screen lookups leave retryTransient out, so this pins its default. + let profile = try await ContactsManager.resolveContactProfile( + publicKey: contactProfileKey, + includePlaceholder: true, + fetchRemoteProfile: { try await stub.fetch($0) } + ) + + XCTAssertEqual(profile.name, Bitkit.PubkyProfile.placeholder(publicKey: contactProfileKey).name) + let attempts = await stub.attempts + XCTAssertEqual(attempts, 1) + } + + func testUserInitiatedContactProfileLookupRetriesATransportErrorOnlyOnce() async { let placeholderName = Bitkit.PubkyProfile.placeholder(publicKey: contactProfileKey).name let found: Result = .success(makeProfile(publicKey: contactProfileKey)) - let cases: [(name: String, retryTransient: Bool, outcomes: [Result], expectedName: String, attempts: Int)] = [ - ("bulk lookup does not retry", false, [.failure(profileTransportError), found], placeholderName, 1), - ("user-initiated lookup retries once", true, [.failure(profileTransportError), found], "Alice", 2), - ("user-initiated lookup falls back to the placeholder after one retry", true, [.failure(profileTransportError)], placeholderName, 2), + let cases: [(name: String, outcomes: [Result], expectedName: String)] = [ + ("user-initiated lookup retries once", [.failure(profileTransportError), found], "Alice"), + ("user-initiated lookup falls back to the placeholder after one retry", [.failure(profileTransportError)], placeholderName), ] for testCase in cases { let stub = ContactProfileFetchStub(testCase.outcomes) - let profile = try await ContactsManager.resolveContactProfile( + let profile = try? await ContactsManager.resolveContactProfile( publicKey: contactProfileKey, includePlaceholder: true, - retryTransient: testCase.retryTransient, + retryTransient: true, fetchRemoteProfile: { try await stub.fetch($0) } ) - XCTAssertEqual(profile.name, testCase.expectedName, testCase.name) + XCTAssertEqual(profile?.name, testCase.expectedName, testCase.name) let attempts = await stub.attempts - XCTAssertEqual(attempts, testCase.attempts, testCase.name) + XCTAssertEqual(attempts, 2, testCase.name) } } @@ -731,11 +746,11 @@ final class ContactsManagerTests: XCTestCase { let attempts = await interactive.attempts XCTAssertEqual(attempts, 1, "\(testCase.name): concurrent callers share one lookup, and the contact is not looked up again") - let row = try XCTUnwrap(manager.contacts.first { $0.publicKey == contactProfileKey }?.profile, testCase.name) - XCTAssertEqual(row.name, testCase.row.name, testCase.name) - XCTAssertEqual(row.bio, testCase.row.bio, "\(testCase.name): an edit made now keeps the bio the lookup found") - XCTAssertEqual(row.imageUrl, testCase.row.imageUrl, testCase.name) - XCTAssertEqual(row.links.map(\.url), testCase.row.links.map(\.url), testCase.name) + let row = manager.contacts.first { $0.publicKey == contactProfileKey }?.profile + XCTAssertEqual(row?.name, testCase.row.name, testCase.name) + XCTAssertEqual(row?.bio, testCase.row.bio, "\(testCase.name): an edit made now keeps the bio the lookup found") + XCTAssertEqual(row?.imageUrl, testCase.row.imageUrl, testCase.name) + XCTAssertEqual(row?.links.map(\.url), testCase.row.links.map(\.url), testCase.name) XCTAssertEqual(manager.contacts.map(\.displayName), testCase.names, testCase.name) await bulk.release() await manager.waitForProfileRefreshForTesting() @@ -833,10 +848,10 @@ final class ContactsManagerTests: XCTestCase { await bulk.release() await manager.waitForProfileRefreshForTesting() - let row = try XCTUnwrap(manager.contacts.first?.profile) - XCTAssertEqual(row.name, "Label only", "A background result for a contact a screen took over must not change its row, \(message)") - XCTAssertEqual(row.bio, "", message) - XCTAssertNil(row.imageUrl, message) + let row = manager.contacts.first?.profile + XCTAssertEqual(row?.name, "Label only", "A background result for a contact a screen took over must not change its row, \(message)") + XCTAssertEqual(row?.bio, "", message) + XCTAssertNil(row?.imageUrl, message) try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { _ in throw profileTransportError }) XCTAssertEqual(manager.contacts.map(\.displayName), ["Label only"], "Nor may the next load show it, \(message)") await manager.waitForProfileRefreshForTesting() diff --git a/BitkitTests/PaykitPublicReadLaneTests.swift b/BitkitTests/PaykitPublicReadLaneTests.swift index 68773bed4..afef45fcd 100644 --- a/BitkitTests/PaykitPublicReadLaneTests.swift +++ b/BitkitTests/PaykitPublicReadLaneTests.swift @@ -3,8 +3,9 @@ import Paykit import XCTest final class PaykitPublicReadLaneTests: XCTestCase { - /// Five bulk reads of each kind start, each blocked on its first network read. Only four may run, and a contact record - /// read under the SDK lock and an interactive fetch must both finish meanwhile. + /// Six bulk reads of each kind start, each blocked on its first network read. Only four may run. The two waiting for a + /// bulk slot must hold no read slot, or the six reads would take all six read slots. A contact record read under the + /// SDK lock and an interactive fetch must both finish meanwhile. func testBulkReadsLeaveTheSdkLockAndReadSlotsFree() async throws { let cases: [(name: String, read: @Sendable (PaykitSdkService, String) async throws -> Void)] = [ ("profile lookup", { service, publicKey in @@ -30,7 +31,7 @@ final class PaykitPublicReadLaneTests: XCTestCase { for testCase in cases { let sdk = PublicReadLaneSdk(noPointer: .init()) let service = PaykitSdkService(sdkFactory: { sdk }) - let reads = (0 ..< 5).map { index in + let reads = (0 ..< 6).map { index in Task { try await testCase.read(service, "contact\(index)") } } try await sdk.log.waitForEntries(count: 4) @@ -51,10 +52,9 @@ final class PaykitPublicReadLaneTests: XCTestCase { await fulfillment(of: [lockedReadFinished, fetched], timeout: 2) await sdk.gate.open() - try await lockedRead.value - try await fetch.value - for read in reads { - try await read.value + for task in [lockedRead, fetch] + reads { + let result = await task.result + XCTAssertNoThrow(try result.get(), testCase.name) } } } diff --git a/BitkitTests/PaykitSdkReadLimiterTests.swift b/BitkitTests/PaykitSdkReadLimiterTests.swift index 9ea1a351e..8e929dfcf 100644 --- a/BitkitTests/PaykitSdkReadLimiterTests.swift +++ b/BitkitTests/PaykitSdkReadLimiterTests.swift @@ -151,10 +151,15 @@ final class PaykitSdkReadLimiterTests: XCTestCase { await fulfillment(of: [abandonedThrew], timeout: 2) holderGate.open() - try await abandoned.value - try await holder.value - try await next.value - try await testCase.queue { await recorder.record("after") } + for read in [abandoned, holder, next] { + let result = await read.result + XCTAssertNoThrow(try result.get(), testCase.name) + } + do { + try await testCase.queue { await recorder.record("after") } + } catch { + XCTFail("\(testCase.name): \(error)") + } let events = await recorder.events XCTAssertEqual(events, ["holder", "next", "after"], testCase.name) } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index fcadc59bb..56031308d 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1331,7 +1331,7 @@ final class PubkyProfileManagerTests: XCTestCase { for testCase in cases { AdoptedPubkyReference.current = nil await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], caseName: testCase.name) let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) await stub.setHoldsRequests(true) @@ -1371,11 +1371,11 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testRingAdoptionReusesTheFoundRowProfileWhateverFormTheAdoptedKeyTakesAndClearsTheRows() async throws { + func testRingAdoptionReusesTheFoundRowProfileWhateverFormTheAdoptedKeyTakesAndClearsTheRows() async { // Rows are cached under the normalized pubky. for (name, adoptedKey) in [("prefixed key", ringKeyA), ("bare key", bareRingKeyA)] { - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + await withRestoredProfileDefaults(case: name) { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], caseName: name) let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) await manager.loadRingIdentityProfiles([bareRingKeyA, bareRingKeyB]) // Held from here on, so adoption only returns if it reads nothing remote itself. @@ -1392,18 +1392,18 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertTrue(manager.ringIdentityProfiles.isEmpty, name) await stub.waitForRequests(3) + await stub.release(request: 2) + await waitUntil("\(name): the background refresh finishes") { !manager.isLoadingProfile } let requests = await stub.requests XCTAssertEqual(Set(requests.prefix(2)), [ringKeyA, ringKeyB], name) XCTAssertEqual(requests.dropFirst(2), [adoptedKey], "\(name): only the background refresh reads the row's profile again") - await stub.release(request: 2) - await waitUntil("\(name): the background refresh finishes") { !manager.isLoadingProfile } } } } /// Only a definitive not-found undoes the reused row profile and starts profile setup; an offline refresh keeps it. @MainActor - func testRingAdoptionRefreshOfAReusedRowProfile() async throws { + func testRingAdoptionRefreshOfAReusedRowProfile() async { let renamed = makeProfile(publicKey: ringKeyA, name: "Alice Renamed") let cases: [(name: String, change: (RemoteProfileStub) async -> Void, expectedName: String?)] = [ ("renamed", { await $0.setProfile(renamed, for: ringKeyA) }, "Alice Renamed"), @@ -1411,8 +1411,8 @@ final class PubkyProfileManagerTests: XCTestCase { ("offline", { await $0.makeUnreachable(ringKeyA) }, "Alice"), ] for testCase in cases { - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + await withRestoredProfileDefaults(case: testCase.name) { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")], caseName: testCase.name) let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) await manager.loadRingIdentityProfiles([bareRingKeyA]) await testCase.change(stub) @@ -1584,11 +1584,11 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testRingAdoptionWaitsForTheTappedRowLookupAndFetchesOnlyWhenItMisses() async throws { + func testRingAdoptionWaitsForTheTappedRowLookupAndFetchesOnlyWhenItMisses() async { for rowLookupFinds in [true, false] { let name = "rowLookupFinds: \(rowLookupFinds)" - try await withRestoredProfileDefaults { - let stub = RemoteProfileStub(holdsRequests: true) + await withRestoredProfileDefaults(case: name) { + let stub = RemoteProfileStub(holdsRequests: true, caseName: name) let manager = PubkyProfileManager(remoteProfileResolver: { try await stub.resolve($0) }) let rows = Task { await manager.loadRingIdentityProfiles([bareRingKeyA]) } await stub.waitForRequests(1) @@ -1604,12 +1604,16 @@ final class PubkyProfileManagerTests: XCTestCase { // row miss can mean offline, so then the fresh fetch still decides profile setup. await stub.setProfile(makeProfile(publicKey: ringKeyA, name: rowLookupFinds ? "Alice Refetched" : "Alice"), for: ringKeyA) // The second request is the background refresh of a reused profile, or adoption's own fetch after a miss. + // Adoption waits for its own fetch, but must return while that refresh is still held. await stub.waitForRequests(2) - await stub.release(request: 1) + if !rowLookupFinds { + await stub.release(request: 1) + } let adopted = try await adoption.value XCTAssertEqual(adopted?.name, "Alice", name) XCTAssertFalse(manager.isProfileSetupPending, name) + await stub.release(request: 1) await waitUntil("\(name): the background refresh finishes") { !manager.isLoadingProfile } let requests = await stub.requests XCTAssertEqual(requests, [ringKeyA, ringKeyA], name) @@ -2280,6 +2284,21 @@ final class PubkyProfileManagerTests: XCTestCase { try await body() } + /// Runs one case of a table-driven test, so an error it throws fails that case by name and the next case still runs. + @MainActor + private func withRestoredProfileDefaults( + case name: String, + file: StaticString = #filePath, + line: UInt = #line, + _ body: () async throws -> Void + ) async { + do { + try await withRestoredProfileDefaults(body) + } catch { + XCTFail("\(name): \(error)", file: file, line: line) + } + } + /// Fails the test instead of hanging it when `condition` does not hold before the deadline. @MainActor private func waitUntil( @@ -2373,10 +2392,12 @@ private actor RemoteProfileStub { private var isHolding: Bool private var heldRequests: [Int: CheckedContinuation] = [:] private var requestWaiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] + private let caseName: String? - init(profiles: [String: PubkyProfile] = [:], holdsRequests: Bool = false) { + init(profiles: [String: PubkyProfile] = [:], holdsRequests: Bool = false, caseName: String? = nil) { self.profiles = profiles isHolding = holdsRequests + self.caseName = caseName } func resolve(_ key: String) async throws -> PubkyProfile { @@ -2435,15 +2456,19 @@ private actor RemoteProfileStub { private func expireHeldRequest(_ index: Int) { guard let continuation = heldRequests.removeValue(forKey: index) else { return } - XCTFail("Request \(index) was never released") + XCTFail(named("Request \(index) was never released")) continuation.resume() } private func expireRequestWaiter(_ id: UUID, file: StaticString, line: UInt) { guard let waiter = requestWaiters.removeValue(forKey: id) else { return } - XCTFail("Timed out waiting for \(waiter.count) requests; saw \(requests.count)", file: file, line: line) + XCTFail(named("Timed out waiting for \(waiter.count) requests; saw \(requests.count)"), file: file, line: line) waiter.continuation.resume() } + + private func named(_ message: String) -> String { + caseName.map { "\($0): \(message)" } ?? message + } } @MainActor From f677814d87bfdf496c6e8301b75a26273b892870 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 02:50:21 +0200 Subject: [PATCH 56/79] refactor: look the Pubky Ring rows up from .task on the choice screen --- Bitkit/Views/Profile/PubkyChoiceView.swift | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/Bitkit/Views/Profile/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift index 7f625975b..ad1c8d25f 100644 --- a/Bitkit/Views/Profile/PubkyChoiceView.swift +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -11,7 +11,9 @@ struct PubkyChoiceView: View { @State private var ringProfiles: [String: PubkyProfile] = [:] @State private var didLoad = false @State private var adoptingPubky: String? - @State private var loadTask: Task? + /// Bumped to look the rows up again. The `.task` keyed on it stops the previous lookup, and SwiftUI stops it when the + /// screen goes away. + @State private var ringLoadGeneration = 0 private var hasRingIdentities: Bool { !ringPubkys.isEmpty @@ -103,8 +105,7 @@ struct PubkyChoiceView: View { .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) - .onAppear(perform: reloadIdentities) - .onDisappear(perform: cancelLoad) + .task(id: ringLoadGeneration) { await loadIdentities() } .onChange(of: scenePhase) { _, newPhase in guard newPhase == .active else { return } pubkyProfile.forgetRingIdentityMisses() @@ -210,17 +211,13 @@ struct PubkyChoiceView: View { } private func reloadIdentities() { - loadTask?.cancel() - ringPubkys = SharedPubkyKeychain.listRingIdentities() - didLoad = true - - let pubkys = ringPubkys - loadTask = Task { await pubkyProfile.loadRingIdentityProfiles(pubkys) } + ringLoadGeneration += 1 } - private func cancelLoad() { - loadTask?.cancel() - loadTask = nil + private func loadIdentities() async { + ringPubkys = SharedPubkyKeychain.listRingIdentities() + didLoad = true + await pubkyProfile.loadRingIdentityProfiles(ringPubkys) } // MARK: - Background Illustrations From 9a5de71b2c9cd8da8fbd79b4a80411b2ec08c9ef Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 02:50:21 +0200 Subject: [PATCH 57/79] refactor: queue contact tag saves in ContactsManager --- Bitkit/Managers/ContactsManager.swift | 38 ++++++++++++++++++- Bitkit/Views/Contacts/ContactDetailView.swift | 36 +++++------------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 01d61c698..3e0350b2c 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -121,6 +121,8 @@ class ContactsManager: ObservableObject { private var profileRefresh: ContactProfileRefresh? private var profileRefreshCount = 0 private var pendingProfileLookups: [String: Task] = [:] + /// The last tag change queued for each contact, which the next one for that contact waits for. + private var tagChanges: [String: Task] = [:] /// Profiles resolved this session for the owner's contacts, shown in place of stored labels while a load refreshes. private var resolvedProfiles: [String: PubkyProfile] = [:] private var resolvedProfilesOwner: String? @@ -128,17 +130,22 @@ class ContactsManager: ObservableObject { private let contactRecords: @Sendable () async throws -> [ContactRecord] private let fetchFollows: @Sendable (String) async throws -> [String] private let fetchRemoteProfile: @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? + private let saveContactLabel: @Sendable (_ publicKey: String, _ label: String) async throws -> Void init( contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords, fetchFollows: @escaping @Sendable (String) async throws -> [String] = { try await PubkyService.getContacts(publicKey: $0) }, fetchRemoteProfile: @escaping @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? = { try await ContactsManager.remoteContactProfile(publicKey: $0, priority: $1) + }, + saveContactLabel: @escaping @Sendable (_ publicKey: String, _ label: String) async throws -> Void = { + _ = try await PubkyService.saveContact(publicKey: $0, label: $1) } ) { self.contactRecords = contactRecords self.fetchFollows = fetchFollows self.fetchRemoteProfile = fetchRemoteProfile + self.saveContactLabel = saveContactLabel } /// Profile refreshes replace rows without counting as a change to the saved contacts. @@ -586,8 +593,9 @@ class ContactsManager: ObservableObject { tags: tags ) + let saveContactLabel = saveContactLabel try await Task.detached { - _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: name) + try await saveContactLabel(prefixedKey, name) }.value Self.upsertContactProfileOverride(publicKey: prefixedKey, data: contactData) @@ -600,6 +608,34 @@ class ContactsManager: ObservableObject { Logger.info("Updated contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") } + /// Saves a tag change made on a contact's screen over the contact's latest profile once the changes queued before it + /// for that contact are done, so each one applies over the tags the last one saved. It first waits for the lookup of a + /// profile the row is still waiting for, so the save keeps the bio, links and avatar that lookup finds. `shownProfile` + /// stands in when the contact is no longer listed. A failed change does not stop the ones queued after it. + func updateContactTags( + publicKey: String, + shownProfile: PubkyProfile, + transform: @escaping ([String]) -> [String] + ) -> Task { + let key = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + let previousChange = tagChanges[key] + let change = Task { + _ = await previousChange?.result + await resolvePendingContactProfile(publicKey: publicKey) + let latest = contacts.first(where: { $0.publicKey == publicKey })?.profile ?? shownProfile + try await updateContact( + publicKey: publicKey, + name: latest.name, + bio: latest.bio, + imageUrl: latest.imageUrl, + links: latest.links, + tags: transform(latest.tags) + ) + } + tagChanges[key] = change + return change + } + // MARK: - Delete Contact func removeContact(publicKey: String) async throws { diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 45844525b..e95a076c5 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -23,7 +23,6 @@ struct ContactDetailView: View { @State private var showDeleteConfirmation = false @State private var isPayLoading = false @State private var payTask: Task? - @State private var tagUpdate: Task? var body: some View { VStack(spacing: 0) { @@ -235,34 +234,19 @@ struct ContactDetailView: View { updateTags { $0.filter { $0 != tag } } } - /// Shows the change at once, then saves it, one tag change at a time, over the contact's latest profile. It first - /// waits for the lookup of a profile the row is still waiting for, so a tag change keeps the bio, links and avatar - /// that lookup finds. + /// Shows the change at once, then saves it, one tag change at a time, over the contact's latest profile, after the + /// lookup of a profile the row is still waiting for. private func updateTags(_ transform: @escaping ([String]) -> [String]) { guard let current = profile else { return } profile = current.withTags(transform(current.tags)) - let previousUpdate = tagUpdate - tagUpdate = Task { - await previousUpdate?.value - await contactsManager.resolvePendingContactProfile(publicKey: publicKey) - let latest = contactsManager.contacts.first(where: { $0.publicKey == publicKey })?.profile ?? current - await persistContact(latest.withTags(transform(latest.tags))) - } - } - - private func persistContact(_ profile: PubkyProfile) async { - do { - try await contactsManager.updateContact( - publicKey: publicKey, - name: profile.name, - bio: profile.bio, - imageUrl: profile.imageUrl, - links: profile.links, - tags: profile.tags - ) - } catch { - Logger.error("Failed to persist contact tags: \(error)", context: "ContactDetailView") - app.toast(type: .error, title: t("contacts__error_saving")) + let change = contactsManager.updateContactTags(publicKey: publicKey, shownProfile: current, transform: transform) + Task { + do { + try await change.value + } catch { + Logger.error("Failed to persist contact tags: \(error)", context: "ContactDetailView") + app.toast(type: .error, title: t("contacts__error_saving")) + } } } From 5164485d4c7c75ac0bde0a74fdc37220888a3cb3 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 02:50:21 +0200 Subject: [PATCH 58/79] test: check tag changes queued during a contact's lookup keep its profile and each other --- BitkitTests/ContactsManagerTests.swift | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index cf57d9dcb..432a0298e 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -906,6 +906,52 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(form.links.map(\.url), ["https://alice.example"]) } + func testTagChangesQueuedDuringTheContactsLookupKeepTheProfileItFindsAndEachOther() async throws { + let savedOverrides = ContactsManager.backupContactProfileOverrides() + addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } + let savedLabels = SavedContactLabels() + let manager = ContactsManager( + fetchRemoteProfile: { _, _ in throw profileTransportError }, + saveContactLabel: { await savedLabels.save($0, label: $1) } + ) + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + + // The contact's screen looks its label-only row up, and that lookup is held. + let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) + await interactive.hold() + let screenLookup = Task { + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + } + while await interactive.heldCount < 1 { + await Task.yield() + } + + // The user adds two tags while it is still held. + let shown = try XCTUnwrap(manager.contacts.first?.profile) + let first = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown) { $0 + ["friend"] } + let second = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown.withTags(["friend"])) { $0 + ["work"] } + await interactive.release() + await screenLookup.value + try await first.value + try await second.value + + let saved = try XCTUnwrap(ContactsManager.backupContactProfileOverrides()?[contactProfileKey]) + XCTAssertEqual(saved.tags, ["friend", "work"], "The second change saves over the first, so neither tag is lost") + XCTAssertEqual(saved.name, "Alice", "The changes wait for the lookup, so they save over the profile it finds") + XCTAssertEqual(saved.bio, "Hello") + XCTAssertEqual(saved.image, "pubky://alice/avatar") + XCTAssertEqual(saved.links.map(\.url), ["https://alice.example"]) + XCTAssertEqual(manager.contacts.first.map { PubkyProfileData.from(profile: $0.profile) }, saved, "The row shows what was saved") + let labels = await savedLabels.labels + XCTAssertEqual(labels, ["Alice", "Alice"], "Each change saves the contact once, under the name the lookup found") + + await bulk.release() + await manager.waitForProfileRefreshForTesting() + } + func testPreparingAnImportLooksFollowsUpOnTheInteractiveLane() async throws { let lanes = ProfileLookupLanes() let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] @@ -1247,6 +1293,15 @@ private actor ContactProfileFetchStub { } } +/// Records the label of each contact save. +private actor SavedContactLabels { + private(set) var labels: [String] = [] + + func save(_: String, label: String) { + labels.append(label) + } +} + private actor SuspendedContactRecords { private var records: [ContactRecord] private var continuation: CheckedContinuation? From 17a2b214ebed206084a60483ff5f6ea0eb0fad68 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 08:10:44 +0200 Subject: [PATCH 59/79] fix: give freed public read slots to interactive reads before queued bulk reads --- Bitkit/Services/PubkyService.swift | 48 ++++++++++++----- BitkitTests/PaykitSdkReadLimiterTests.swift | 57 +++++++++++++++++++++ 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index fef97e77f..a443b10a8 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -47,8 +47,9 @@ struct PubkyRegisteredIdentity { } /// Which public read slots a read may use. Background work that reads for many contacts at once is `bulk`, so it can -/// never take every read slot from reads for what the user is looking at. Reads the user is waiting on are -/// `interactive` even when there are many of them, such as the follow lookups that prepare a contact import. +/// never take every read slot from reads for what the user is looking at, and a freed read slot goes to those reads +/// first. Reads the user is waiting on are `interactive` even when there are many of them, such as the follow lookups +/// that prepare a contact import. enum PaykitPublicReadPriority { case interactive case bulk @@ -1558,26 +1559,28 @@ final class PaykitSdkOperationLock: @unchecked Sendable { } } -/// Caps concurrent operations. Waiters are served in FIFO order and leave the queue as soon as their task is -/// cancelled, without taking a slot, so an abandoned read never runs ahead of work queued after it. +/// Caps concurrent operations. A freed slot goes to the oldest interactive waiter, or to the oldest bulk waiter when no +/// interactive one is queued, so bulk work already waiting never delays an interactive read that arrives after it. +/// Waiters leave the queue as soon as their task is cancelled, without taking a slot, so an abandoned read never runs +/// ahead of work queued after it. final class PaykitSdkReadLimiter: @unchecked Sendable { private let lock = NSLock() private let maxConcurrent: Int private var inFlight = 0 - private var waiters: [(id: UUID, continuation: CheckedContinuation)] = [] + private var waiters: [(id: UUID, priority: PaykitPublicReadPriority, continuation: CheckedContinuation)] = [] init(maxConcurrent: Int) { self.maxConcurrent = maxConcurrent } - func withSlot(_ operation: () async throws -> T) async throws -> T { - try await acquire() + func withSlot(priority: PaykitPublicReadPriority = .interactive, _ operation: () async throws -> T) async throws -> T { + try await acquire(priority: priority) defer { release() } try Task.checkCancellation() return try await operation() } - private func acquire() async throws { + private func acquire(priority: PaykitPublicReadPriority) async throws { let id = UUID() try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in @@ -1590,7 +1593,7 @@ final class PaykitSdkReadLimiter: @unchecked Sendable { lock.unlock() continuation.resume() } else { - waiters.append((id, continuation)) + waiters.append((id, priority, continuation)) lock.unlock() } } @@ -1606,7 +1609,7 @@ final class PaykitSdkReadLimiter: @unchecked Sendable { return waiters.remove(at: index).continuation } - /// Hands the slot straight to the oldest waiter, so a newcomer cannot overtake the queue. + /// Hands the slot straight to the next waiter instead of freeing it, so a newcomer cannot take it first. private func release() { lock.lock() guard !waiters.isEmpty else { @@ -1614,16 +1617,27 @@ final class PaykitSdkReadLimiter: @unchecked Sendable { lock.unlock() return } - let next = waiters.removeFirst() + let index = waiters.firstIndex { $0.priority == .interactive } ?? waiters.startIndex + let next = waiters.remove(at: index) lock.unlock() next.continuation.resume() } + + #if DEBUG + var waiterCountForTesting: Int { + lock.lock() + defer { lock.unlock() } + return waiters.count + } + #endif } /// Slots for the public read lane. Every read holds one of `readCap` read slots while it runs. A bulk read first takes /// one of `bulkCap` bulk slots, so bulk work holds at most `bulkCap` read slots and the rest stay free for interactive -/// reads. A bulk read takes its bulk slot before its read slot and never waits for a bulk slot while holding a read -/// slot, so the two limiters cannot deadlock. Each limiter keeps its own FIFO order and cancellation behaviour. +/// reads. A freed read slot goes to a queued interactive read before any bulk read queued for one, so bulk reads wait +/// while interactive reads keep every read slot busy. A bulk read takes its bulk slot before its read slot and never +/// waits for a bulk slot while holding a read slot, so the two limiters cannot deadlock. Each limiter keeps arrival +/// order within a priority and leaves the queue on cancellation. final class PaykitPublicReadSlots: Sendable { private let readSlots: PaykitSdkReadLimiter private let bulkSlots: PaykitSdkReadLimiter @@ -1639,10 +1653,16 @@ final class PaykitPublicReadSlots: Sendable { return try await readSlots.withSlot(operation) case .bulk: return try await bulkSlots.withSlot { - try await readSlots.withSlot(operation) + try await readSlots.withSlot(priority: .bulk, operation) } } } + + #if DEBUG + var readWaiterCountForTesting: Int { + readSlots.waiterCountForTesting + } + #endif } extension PublicPaykitService.Endpoint { diff --git a/BitkitTests/PaykitSdkReadLimiterTests.swift b/BitkitTests/PaykitSdkReadLimiterTests.swift index 8e929dfcf..b127bc62f 100644 --- a/BitkitTests/PaykitSdkReadLimiterTests.swift +++ b/BitkitTests/PaykitSdkReadLimiterTests.swift @@ -63,6 +63,14 @@ final class PaykitSdkReadLimiterTests: XCTestCase { } } + private func waitForReadWaiters(_ slots: PaykitPublicReadSlots, count: Int) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while slots.readWaiterCountForTesting < count, ContinuousClock.now < deadline { + await Task.yield() + } + XCTAssertEqual(slots.readWaiterCountForTesting, count, "Reads queued for a read slot") + } + func testConcurrentReadsNeverExceedTheCap() async throws { let limiter = PaykitSdkReadLimiter(maxConcurrent: 2) let recorder = Recorder() @@ -233,6 +241,55 @@ final class PaykitSdkReadLimiterTests: XCTestCase { XCTAssertEqual(finalEvents.last, "bulk4") } + /// Six interactive reads hold every read slot and four bulk reads, holding all four bulk slots, queue for one. An + /// interactive read that queues after them still takes the next freed read slot. The bulk reads then take the read + /// slots freed after that in the order they queued. + func testInteractiveReadTakesAFreedReadSlotBeforeBulkReadsAlreadyQueuedForOne() async throws { + let slots = PaykitPublicReadSlots() + let recorder = Recorder() + var gates: [String: Gate] = [:] + var reads: [String: Task] = [:] + func start(_ name: String, _ priority: PaykitPublicReadPriority) { + let gate = Gate() + gates[name] = gate + reads[name] = startGatedRead(name, on: slots, priority: priority, recorder: recorder, gate: gate) + } + func finish(_ name: String) async throws { + gates[name]?.open() + try await reads[name]?.value + } + + for index in 0 ..< 6 { + start("interactive\(index)", .interactive) + } + try await waitForEvents(recorder, count: 6) + for index in 0 ..< 4 { + start("bulk\(index)", .bulk) + await waitForReadWaiters(slots, count: index + 1) + } + start("late interactive", .interactive) + await waitForReadWaiters(slots, count: 5) + + try await finish("interactive0") + try await waitForEvents(recorder, count: 7) + let eventsAfterFirstRelease = await recorder.events + XCTAssertEqual(eventsAfterFirstRelease.last, "late interactive", "A freed read slot goes to the queued interactive read") + XCTAssertEqual(slots.readWaiterCountForTesting, 4) + + for index in 0 ..< 4 { + try await finish("interactive\(index + 1)") + try await waitForEvents(recorder, count: 8 + index) + let events = await recorder.events + XCTAssertEqual(events.last, "bulk\(index)", "Bulk reads take freed read slots in the order they queued") + } + + for name in gates.keys { + try await finish(name) + } + let finalEvents = await recorder.events + XCTAssertEqual(finalEvents.count, 11) + } + func testFailingReadReleasesItsSlotToTheNextWaiter() async throws { let limiter = PaykitSdkReadLimiter(maxConcurrent: 1) let recorder = Recorder() From b3f2eaf43924880cab8ff7c20a18749d276e5c1f Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 08:15:25 +0200 Subject: [PATCH 60/79] fix: keep a contacts load quiet when its record read fails after cancellation --- Bitkit/Managers/ContactsManager.swift | 4 +++- BitkitTests/ContactsManagerTests.swift | 25 ++++++++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 3e0350b2c..b304878eb 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -238,7 +238,8 @@ class ContactsManager: ObservableObject { /// Publishes the saved records straight away, each with the best profile already known, then looks the remaining /// profiles up in the background on the bulk read lane and updates rows as they resolve. A failed lookup leaves its - /// row as it is. A cancelled or failed load publishes nothing and leaves a running refresh to finish. + /// row as it is. A cancelled or failed load publishes nothing and leaves a running refresh to finish. A cancelled + /// load returns without an error even when the record read throws once cancelled, as the SDK lock does. func loadContacts( for publicKey: String, fetchContactRecords: @escaping @Sendable () async throws -> [Paykit.ContactRecord], @@ -285,6 +286,7 @@ class ContactsManager: ObservableObject { Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager") return } catch { + guard !Task.isCancelled, generation == loadGeneration else { return } guard contactsRevision == revision else { continue } if Self.isMissingContactsDataError(error) { contacts = [] diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 432a0298e..aeed68476 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -189,16 +189,31 @@ final class ContactsManagerTests: XCTestCase { } /// General Settings used to await `loadContactsIfNeeded` in its `.task`, so leaving mid-load surfaced the cancellation - /// as an error toast. Another caller still finishes the load whichever screen's load it waited on is cancelled. + /// as an error toast. Another caller still finishes the load whichever screen's load it waited on is cancelled. The + /// Contacts screen's cancelled load reports no error, also when its record read throws once cancelled, as a read + /// waiting for the SDK lock does. func testLoadContactsIfNeededFinishesAfterTheLoadItWaitedOnIsCancelled() async throws { - let cases: [(name: String, cancelledLoadThrows: Bool, cancelledLoad: @MainActor (ContactsManager) async throws -> Void)] = [ - ("another loadContactsIfNeeded", true, { try await $0.loadContactsIfNeeded(for: "owner") }), - ("the Contacts screen's loadContacts", false, { try await $0.loadContacts(for: "owner") }), + let cases: [( + name: String, + cancelledLoadThrows: Bool, + recordReadThrowsOnceCancelled: Bool, + cancelledLoad: @MainActor (ContactsManager) async throws -> Void + )] = [ + ("another loadContactsIfNeeded", true, false, { try await $0.loadContactsIfNeeded(for: "owner") }), + ("the Contacts screen's loadContacts", false, false, { try await $0.loadContacts(for: "owner") }), + ("the Contacts screen's loadContacts, record read throws", false, true, { try await $0.loadContacts(for: "owner") }), ] for testCase in cases { let record = contactRecord(key: "pubky" + String(repeating: "y", count: 52), name: "Contact") let source = SuspendedContactRecords(records: [record]) - let manager = ContactsManager(contactRecords: { await source.load() }) + let recordReadThrowsOnceCancelled = testCase.recordReadThrowsOnceCancelled + let manager = ContactsManager(contactRecords: { + let records = await source.load() + if recordReadThrowsOnceCancelled { + try Task.checkCancellation() + } + return records + }) let cancelledLoad = Task { try await testCase.cancelledLoad(manager) } while await !(source.isPaused) { await Task.yield() From ed82ce5d30385320eb24cbe3731e29cf4ec51bd5 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 08:20:27 +0200 Subject: [PATCH 61/79] fix: stop a contact payments change when Pubky signs out during its contacts load --- Bitkit/Managers/PubkyProfileManager.swift | 14 +++ Bitkit/Services/ContactPaymentsService.swift | 34 ++++++ .../Views/Settings/GeneralSettingsView.swift | 14 +-- BitkitTests/ContactPaymentsServiceTests.swift | 104 ++++++++++++++++++ 4 files changed, 157 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 897fed813..c1e76cb25 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1284,6 +1284,20 @@ class PubkyProfileManager: ObservableObject { publicKey != nil } + /// The signed-in session as work that started for it saw it. Work that waits compares it with `currentSession` + /// afterwards to tell whether that session ended or changed meanwhile. + struct SignedInSession: Equatable { + let publicKey: String + fileprivate let revision: UUID + } + + /// Nil while signed out and while a sign-in, sign-out or other session change runs, and different after one, from + /// the moment it starts and before it clears any state. + var currentSession: SignedInSession? { + guard let publicKey, Self.sessionMutationCount == 0 else { return nil } + return SignedInSession(publicKey: publicKey, revision: Self.sessionRevision) + } + var hasLocalSecretKeyForCurrentProfile: Bool { Self.hasLocalSecretKey(for: publicKey) } diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 17cac48f6..542184aad 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -68,6 +68,40 @@ enum ContactPaymentsService { ) } + /// Turns contact payments on or off for the signed-in Pubky session. Private endpoints are prepared for the saved + /// contacts, so it first waits for the first contacts load. If the session ended or changed by the end of that wait, + /// it returns without writing the preference or touching endpoints, so a change started before a sign-out never + /// turns sharing back on after it. Nothing suspends between that check and the enable's preference writes, so a + /// sign-out that starts after the check clears what the enable wrote. + @MainActor + static func setEnabled( + _ enabled: Bool, + pubkyProfile: PubkyProfileManager, + contactsManager: ContactsManager, + operations: Operations, + defaults: UserDefaults = .standard + ) async throws { + guard let session = pubkyProfile.currentSession else { return } + let canUsePrivatePayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile + if canUsePrivatePayments { + do { + try await contactsManager.loadContactsIfNeeded(for: session.publicKey) + } catch { + guard pubkyProfile.currentSession == session else { return } + throw error + } + } + guard pubkyProfile.currentSession == session else { return } + + try await setEnabled( + enabled, + contactPublicKeys: contactsManager.contacts.map(\.publicKey), + canUsePrivatePayments: canUsePrivatePayments, + operations: operations, + defaults: defaults + ) + } + @MainActor static func setEnabled( _ enabled: Bool, diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index 97f3e27d7..9e745d168 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -150,7 +150,8 @@ struct GeneralSettingsView: View { !hasConfirmedContactPaymentsPreference else { return } - // Leaving the screen cancels `.task`; its own task lets the first contacts load and the enable still finish. + // Leaving the screen cancels `.task`; its own task lets the first contacts load and the enable still finish + // unless the Pubky session ends first. Task { await updateContactPayments(true) } } } @@ -166,16 +167,11 @@ struct GeneralSettingsView: View { } do { - let canUsePrivatePayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile - if canUsePrivatePayments, let publicKey = pubkyProfile.publicKey { - try await contactsManager.loadContactsIfNeeded(for: publicKey) - } - try await ContactPaymentsService.setEnabled( enabled, - wallet: wallet, - contactPublicKeys: contactsManager.contacts.map(\.publicKey), - canUsePrivatePayments: canUsePrivatePayments + pubkyProfile: pubkyProfile, + contactsManager: contactsManager, + operations: .live(wallet: wallet) ) } catch is CancellationError { return diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index a3ece80a3..6f7d26120 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -1,5 +1,7 @@ @testable import Bitkit import Foundation +import struct Paykit.ContactRecord +import struct Paykit.PaykitProfile import XCTest @MainActor @@ -231,6 +233,108 @@ final class ContactPaymentsServiceTests: XCTestCase { } } + /// General Settings turns contact payments on in a task that outlives the screen, after the first contacts load. A + /// Pubky sign-out during that load, finished or still running, stops the change before it writes the preference or + /// publishes anything, and a load that then fails reports nothing. + func testContactPaymentsChangeStopsWhenPubkySignsOutDuringTheContactsLoad() async throws { + enum SignOut { + case never, finished, running + } + let cases: [(name: String, signOut: SignOut, loadFails: Bool)] = [ + ("still signed in", .never, false), + ("signed out", .finished, false), + ("sign-out still running", .running, false), + ("signed out, then the load failed", .finished, true), + ] + snapshotAppDefaultsDomain() + let savedReference = AdoptedPubkyReference.current + let savedSecretKey = try Keychain.load(key: .pubkySecretKey) + addTeardownBlock { + AdoptedPubkyReference.current = savedReference + if let savedSecretKey { + try? Keychain.upsert(key: .pubkySecretKey, data: savedSecretKey) + } else { + try? Keychain.delete(key: .pubkySecretKey) + } + } + let secretKeyHex = String(repeating: "01", count: 32) + try Keychain.upsert(key: .pubkySecretKey, data: Data(secretKeyHex.utf8)) + let rawOwnerKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) + let ownerKey = rawOwnerKey.hasPrefix("pubky") ? rawOwnerKey : "pubky\(rawOwnerKey)" + let contactKey = "pubky" + String(repeating: "y", count: 52) + let record = ContactRecord( + publicKey: contactKey, receiverPaths: [PaykitReceiverPath.wallet], label: "Contact", + profile: PaykitProfile(displayName: "Contact", imageUri: nil, extraJson: nil), + profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", + publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, + publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil + ) + + for testCase in cases { + try await withIsolatedDefaultsAsync { defaults in + let pubkyProfile = PubkyProfileManager() + pubkyProfile.publicKey = ownerKey + pubkyProfile.authState = .authenticated + XCTAssertTrue(pubkyProfile.hasLocalSecretKeyForCurrentProfile, testCase.name) + let loadStarted = expectation(description: "\(testCase.name): contacts load started") + let (loadGate, releaseLoad) = AsyncStream.makeStream() + let loadFails = testCase.loadFails + let contactsManager = ContactsManager(contactRecords: { + loadStarted.fulfill() + for await _ in loadGate {} + if loadFails { + throw PubkyServiceError.sessionNotActive + } + return [record] + }) + let operations = OperationsSpy() + let change = Task { + try await ContactPaymentsService.setEnabled( + true, + pubkyProfile: pubkyProfile, + contactsManager: contactsManager, + operations: operations.makeOperations(), + defaults: defaults + ) + } + await fulfillment(of: [loadStarted], timeout: 2) + + var runningSignOut: (task: Task, release: AsyncStream.Continuation)? + switch testCase.signOut { + case .never: + break + case .finished: + try await pubkyProfile.signOut(performSessionCleanup: {}) + case .running: + let cleanupStarted = expectation(description: "\(testCase.name): sign-out started") + let (cleanupGate, releaseCleanup) = AsyncStream.makeStream() + let signOut = Task { + try await pubkyProfile.signOut(performSessionCleanup: { + cleanupStarted.fulfill() + for await _ in cleanupGate {} + }) + } + await fulfillment(of: [cleanupStarted], timeout: 2) + runningSignOut = (signOut, releaseCleanup) + } + releaseLoad.finish() + let result = await change.result + XCTAssertNoThrow(try result.get(), testCase.name) + if let runningSignOut { + runningSignOut.release.finish() + try await runningSignOut.task.value + } + + let enabled = testCase.signOut == .never + XCTAssertEqual(operations.calls, enabled ? ["private:publish", "public:true"] : [], testCase.name) + XCTAssertEqual(operations.privatePublications.map(\.contactPublicKeys), enabled ? [[contactKey]] : [], testCase.name) + XCTAssertEqual(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), enabled, testCase.name) + XCTAssertEqual(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), enabled, testCase.name) + XCTAssertEqual(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), enabled, testCase.name) + } + } + } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { let suiteName = "ContactPaymentsServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) From ed6b97a4a69fabc548350e7a51f8329d5cd1eca4 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 08:26:27 +0200 Subject: [PATCH 62/79] docs: clarify when the current Pubky session is nil or changes --- Bitkit/Managers/PubkyProfileManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index c1e76cb25..4480b559e 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1291,8 +1291,8 @@ class PubkyProfileManager: ObservableObject { fileprivate let revision: UUID } - /// Nil while signed out and while a sign-in, sign-out or other session change runs, and different after one, from - /// the moment it starts and before it clears any state. + /// Nil while signed out, and from the moment a sign-in, sign-out or other session change starts until it ends, so + /// before that change clears any state. The session after a change compares different from the one before. var currentSession: SignedInSession? { guard let publicKey, Self.sessionMutationCount == 0 else { return nil } return SignedInSession(publicKey: publicKey, revision: Self.sessionRevision) From 93a3e316ff3570c81e073975c45958e89be2fabb Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 09:09:52 +0200 Subject: [PATCH 63/79] fix: stop a contact payments change from publishing or writing state after Pubky signs out --- Bitkit/Services/ContactPaymentsService.swift | 109 +++++--- .../PrivatePaykitService+Contacts.swift | 19 +- Bitkit/Services/PublicPaykitService.swift | 31 ++- BitkitTests/ContactPaymentsServiceTests.swift | 259 ++++++++++++++++-- 4 files changed, 344 insertions(+), 74 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 542184aad..e239e2611 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -3,9 +3,16 @@ import Foundation enum ContactPaymentsService { static let confirmedPreferenceKey = "hasConfirmedPublicPaykitEndpoints" + /// True while the Pubky session a contact payments change started for is still the current one. + typealias SessionCheck = @MainActor () -> Bool + struct Operations { - let syncPublicEndpoints: (_ publish: Bool) async throws -> Void - let preparePrivateEndpoints: (_ contactPublicKeys: [String], _ requireImmediatePublication: Bool) async -> Error? + let syncPublicEndpoints: (_ publish: Bool, _ isSessionCurrent: @escaping SessionCheck) async throws -> Void + let preparePrivateEndpoints: ( + _ contactPublicKeys: [String], + _ requireImmediatePublication: Bool, + _ isSessionCurrent: @escaping SessionCheck + ) async -> Error? let removePrivateEndpoints: () async throws -> Void let setPublicCleanupPending: (_ isPending: Bool) -> Void let setPrivateCleanupPending: (_ isPending: Bool) -> Void @@ -13,14 +20,15 @@ enum ContactPaymentsService { @MainActor static func live(wallet: WalletViewModel) -> Operations { Operations( - syncPublicEndpoints: { publish in - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish) + syncPublicEndpoints: { publish, isSessionCurrent in + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish, isSessionCurrent: isSessionCurrent) }, - preparePrivateEndpoints: { contactPublicKeys, requireImmediatePublication in + preparePrivateEndpoints: { contactPublicKeys, requireImmediatePublication, isSessionCurrent in await PrivatePaykitService.shared.prepareSavedContacts( contactPublicKeys, wallet: wallet, - requireImmediatePublication: requireImmediatePublication + requireImmediatePublication: requireImmediatePublication, + isSessionCurrent: isSessionCurrent ) }, removePrivateEndpoints: { @@ -69,10 +77,10 @@ enum ContactPaymentsService { } /// Turns contact payments on or off for the signed-in Pubky session. Private endpoints are prepared for the saved - /// contacts, so it first waits for the first contacts load. If the session ended or changed by the end of that wait, - /// it returns without writing the preference or touching endpoints, so a change started before a sign-out never - /// turns sharing back on after it. Nothing suspends between that check and the enable's preference writes, so a - /// sign-out that starts after the check clears what the enable wrote. + /// contacts, so it first waits for the first contacts load. Once that session ends or changes, the change stops and + /// returns quietly: it writes no preference, publishing flag or cleared cleanup mark, and its endpoint publications + /// check the session under the locks that sign-out's endpoint removal also takes, so they never write endpoints back + /// after that removal. @MainActor static func setEnabled( _ enabled: Bool, @@ -82,33 +90,44 @@ enum ContactPaymentsService { defaults: UserDefaults = .standard ) async throws { guard let session = pubkyProfile.currentSession else { return } + let isSessionCurrent: SessionCheck = { pubkyProfile.currentSession == session } let canUsePrivatePayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile if canUsePrivatePayments { do { try await contactsManager.loadContactsIfNeeded(for: session.publicKey) } catch { - guard pubkyProfile.currentSession == session else { return } + guard isSessionCurrent() else { return } throw error } } - guard pubkyProfile.currentSession == session else { return } + guard isSessionCurrent() else { return } - try await setEnabled( - enabled, - contactPublicKeys: contactsManager.contacts.map(\.publicKey), - canUsePrivatePayments: canUsePrivatePayments, - operations: operations, - defaults: defaults - ) + do { + try await setEnabled( + enabled, + contactPublicKeys: contactsManager.contacts.map(\.publicKey), + canUsePrivatePayments: canUsePrivatePayments, + operations: operations, + defaults: defaults, + isSessionCurrent: isSessionCurrent + ) + } catch { + guard isSessionCurrent() else { return } + throw error + } } + /// Once `isSessionCurrent` is false, the preference, flags and cleanup marks belong to the sign-out or other session + /// change that started, so it is checked right before each write here, with nothing suspending in between, and the + /// endpoint publications check it again under their locks. @MainActor static func setEnabled( _ enabled: Bool, contactPublicKeys: [String], canUsePrivatePayments: Bool, operations: Operations, - defaults: UserDefaults = .standard + defaults: UserDefaults = .standard, + isSessionCurrent: @escaping SessionCheck = { true } ) async throws { enableAllPaymentOptions(defaults: defaults) @@ -126,10 +145,11 @@ enum ContactPaymentsService { contactPublicKeys: contactPublicKeys, canUsePrivatePayments: canUsePrivatePayments, operations: operations, - defaults: defaults + defaults: defaults, + isSessionCurrent: isSessionCurrent ) } else { - try await disable(operations: operations, defaults: defaults) + try await disable(operations: operations, defaults: defaults, isSessionCurrent: isSessionCurrent) } } catch { await restore( @@ -137,7 +157,8 @@ enum ContactPaymentsService { contactPublicKeys: contactPublicKeys, canUsePrivatePayments: canUsePrivatePayments, operations: operations, - defaults: defaults + defaults: defaults, + isSessionCurrent: isSessionCurrent ) throw error } @@ -148,8 +169,10 @@ enum ContactPaymentsService { contactPublicKeys: [String], canUsePrivatePayments: Bool, operations: Operations, - defaults: UserDefaults + defaults: UserDefaults, + isSessionCurrent: @escaping SessionCheck ) async throws { + guard isSessionCurrent() else { return } if !canUsePrivatePayments, defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey) { operations.setPrivateCleanupPending(true) } @@ -160,14 +183,16 @@ enum ContactPaymentsService { if canUsePrivatePayments, let error = await operations.preparePrivateEndpoints( contactPublicKeys, - true + true, + isSessionCurrent ) { throw error } - try await operations.syncPublicEndpoints(true) + try await operations.syncPublicEndpoints(true, isSessionCurrent) + guard isSessionCurrent() else { return } operations.setPublicCleanupPending(false) if canUsePrivatePayments { operations.setPrivateCleanupPending(false) @@ -175,10 +200,12 @@ enum ContactPaymentsService { } @MainActor - private static func disable(operations: Operations, defaults: UserDefaults) async throws { + private static func disable(operations: Operations, defaults: UserDefaults, isSessionCurrent: @escaping SessionCheck) async throws { do { try await operations.removePrivateEndpoints() - operations.setPrivateCleanupPending(false) + if isSessionCurrent() { + operations.setPrivateCleanupPending(false) + } } catch { operations.setPrivateCleanupPending(true) Logger.warn( @@ -188,13 +215,16 @@ enum ContactPaymentsService { } do { - try await operations.syncPublicEndpoints(false) - operations.setPublicCleanupPending(false) + try await operations.syncPublicEndpoints(false, isSessionCurrent) + if isSessionCurrent() { + operations.setPublicCleanupPending(false) + } } catch { operations.setPublicCleanupPending(true) throw error } + guard isSessionCurrent() else { return } defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(true, forKey: confirmedPreferenceKey) @@ -206,16 +236,20 @@ enum ContactPaymentsService { contactPublicKeys: [String], canUsePrivatePayments: Bool, operations: Operations, - defaults: UserDefaults + defaults: UserDefaults, + isSessionCurrent: @escaping SessionCheck ) async { + guard isSessionCurrent() else { return } let restoresPrivateEndpoints = state.sharesPrivateEndpoints && canUsePrivatePayments defaults.set(state.sharesPublicEndpoints, forKey: PublicPaykitService.publishingEnabledKey) defaults.set(restoresPrivateEndpoints, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(state.hasConfirmedPreference, forKey: confirmedPreferenceKey) do { - try await operations.syncPublicEndpoints(state.sharesPublicEndpoints) - operations.setPublicCleanupPending(state.publicCleanupPending) + try await operations.syncPublicEndpoints(state.sharesPublicEndpoints, isSessionCurrent) + if isSessionCurrent() { + operations.setPublicCleanupPending(state.publicCleanupPending) + } } catch { operations.setPublicCleanupPending(true) Logger.warn("Failed to restore public contact payments: \(error)", context: "ContactPaymentsService") @@ -224,17 +258,20 @@ enum ContactPaymentsService { if restoresPrivateEndpoints { if let error = await operations.preparePrivateEndpoints( contactPublicKeys, - true + true, + isSessionCurrent ) { operations.setPrivateCleanupPending(true) Logger.warn("Failed to restore private contact payments: \(error)", context: "ContactPaymentsService") - } else { + } else if isSessionCurrent() { operations.setPrivateCleanupPending(state.privateCleanupPending) } } else { do { try await operations.removePrivateEndpoints() - operations.setPrivateCleanupPending(state.privateCleanupPending) + if isSessionCurrent() { + operations.setPrivateCleanupPending(state.privateCleanupPending) + } } catch { operations.setPrivateCleanupPending(true) Logger.warn("Failed to clean up private contact payments: \(error)", context: "ContactPaymentsService") diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 314117e12..0b1554a1c 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -27,7 +27,8 @@ extension PrivatePaykitService { func prepareSavedContacts( _ publicKeys: [String], wallet: WalletViewModel, - requireImmediatePublication: Bool = false + requireImmediatePublication: Bool = false, + isSessionCurrent: (@MainActor () -> Bool)? = nil ) async -> Error? { await prepareSavedContacts( publicKeys, @@ -39,7 +40,8 @@ extension PrivatePaykitService { for: publicKeys, wallet: wallet, reason: "prepare", - requireImmediatePublication: requireImmediatePublication + requireImmediatePublication: requireImmediatePublication, + isSessionCurrent: isSessionCurrent ) } ) @@ -351,7 +353,8 @@ extension PrivatePaykitService { wallet: WalletViewModel, reason: String, forceRefreshLightning: Bool = false, - requireImmediatePublication: Bool + requireImmediatePublication: Bool, + isSessionCurrent: (@MainActor () -> Bool)? = nil ) async -> Error? { let operations = endpointPublicationOperations( wallet: wallet, @@ -361,19 +364,27 @@ extension PrivatePaykitService { for: publicKeys, reason: reason, requireImmediatePublication: requireImmediatePublication, + isSessionCurrent: isSessionCurrent, operations: operations ) } + /// Sign-out changes the current Pubky session before it removes private endpoints under the publication lock, so + /// checking `isSessionCurrent` once the lock is held stops a publish that would otherwise write them back after that + /// removal. func syncLocalEndpointPublication( for publicKeys: [String], reason: String, requireImmediatePublication: Bool, + isSessionCurrent: (@MainActor () -> Bool)? = nil, operations: EndpointPublicationOperations ) async -> Error? { do { return try await withPublicationLock { - await syncLocalEndpointPublicationLocked( + if let isSessionCurrent, await !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive + } + return await syncLocalEndpointPublicationLocked( for: publicKeys, reason: reason, requireImmediatePublication: requireImmediatePublication, diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 1cc8d1116..7605e90f3 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -317,7 +317,8 @@ enum PublicPaykitService { @MainActor static func syncPublishedEndpoints( wallet: WalletViewModel, - publish: Bool + publish: Bool, + isSessionCurrent: (@MainActor () -> Bool)? = nil ) async throws { guard publish else { var firstError: Error? @@ -338,8 +339,10 @@ enum PublicPaykitService { } let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) - try await syncLocalReceiverMarker(publicSharingEnabled: true) - try await applyPublishedEndpoints(desiredEndpoints) + // Under the lock, so sign-out's marker removal, which follows its locked endpoint removal, comes after it. + try await applyPublishedEndpoints(desiredEndpoints, unlessSessionEnded: isSessionCurrent) { + try await syncLocalReceiverMarker(publicSharingEnabled: true) + } } @MainActor @@ -446,8 +449,28 @@ enum PublicPaykitService { return invoice.routeHints().contains { !$0.isEmpty } } - private static func applyPublishedEndpoints(_ desiredEndpoints: [Endpoint]) async throws { + /// Runs `operation` under the lock that publishing and removing public endpoints share. Sign-out changes the current + /// Pubky session before it removes endpoints under this lock, so checking `isSessionCurrent` once the lock is held + /// stops a publish that would otherwise write endpoints back after that removal. + static func withEndpointLock( + unlessSessionEnded isSessionCurrent: (@MainActor () -> Bool)? = nil, + _ operation: () async throws -> T + ) async throws -> T { try await endpointLock.withLock { + if let isSessionCurrent, await !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive + } + return try await operation() + } + } + + private static func applyPublishedEndpoints( + _ desiredEndpoints: [Endpoint], + unlessSessionEnded isSessionCurrent: (@MainActor () -> Bool)? = nil, + beforeApplying: () async throws -> Void = {} + ) async throws { + try await withEndpointLock(unlessSessionEnded: isSessionCurrent) { + try await beforeApplying() let report = try await PaykitSdkService.shared.syncPublicEndpoints(desiredEndpoints) guard report.failed.isEmpty else { throw PublicPaykitError.publicationFailed diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 6f7d26120..f65e4bf37 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -2,6 +2,7 @@ import Foundation import struct Paykit.ContactRecord import struct Paykit.PaykitProfile +import struct Paykit.PrivatePaymentListDeliveryReport import XCTest @MainActor @@ -99,7 +100,7 @@ final class ContactPaymentsServiceTests: XCTestCase { let wallet = WalletViewModel() let contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" let operations = OperationsSpy() - operations.preparePrivateEndpoints = { contactPublicKeys, requireImmediatePublication in + operations.preparePrivateEndpoints = { contactPublicKeys, requireImmediatePublication, _ in await service.prepareSavedContacts( contactPublicKeys, wallet: wallet, @@ -246,35 +247,13 @@ final class ContactPaymentsServiceTests: XCTestCase { ("sign-out still running", .running, false), ("signed out, then the load failed", .finished, true), ] - snapshotAppDefaultsDomain() - let savedReference = AdoptedPubkyReference.current - let savedSecretKey = try Keychain.load(key: .pubkySecretKey) - addTeardownBlock { - AdoptedPubkyReference.current = savedReference - if let savedSecretKey { - try? Keychain.upsert(key: .pubkySecretKey, data: savedSecretKey) - } else { - try? Keychain.delete(key: .pubkySecretKey) - } - } - let secretKeyHex = String(repeating: "01", count: 32) - try Keychain.upsert(key: .pubkySecretKey, data: Data(secretKeyHex.utf8)) - let rawOwnerKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) - let ownerKey = rawOwnerKey.hasPrefix("pubky") ? rawOwnerKey : "pubky\(rawOwnerKey)" + let ownerKey = try useLocalPubkySecretKey() let contactKey = "pubky" + String(repeating: "y", count: 52) - let record = ContactRecord( - publicKey: contactKey, receiverPaths: [PaykitReceiverPath.wallet], label: "Contact", - profile: PaykitProfile(displayName: "Contact", imageUri: nil, extraJson: nil), - profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", - publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, - publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil - ) + let record = contactRecord(publicKey: contactKey) for testCase in cases { try await withIsolatedDefaultsAsync { defaults in - let pubkyProfile = PubkyProfileManager() - pubkyProfile.publicKey = ownerKey - pubkyProfile.authState = .authenticated + let pubkyProfile = signedInProfile(ownerKey: ownerKey) XCTAssertTrue(pubkyProfile.hasLocalSecretKeyForCurrentProfile, testCase.name) let loadStarted = expectation(description: "\(testCase.name): contacts load started") let (loadGate, releaseLoad) = AsyncStream.makeStream() @@ -335,6 +314,215 @@ final class ContactPaymentsServiceTests: XCTestCase { } } + /// General Settings turns contact payments on in a task that outlives the screen. A Pubky sign-out that starts while + /// an endpoint publication is in flight stops that enable: a publication that gets its lock after sign-out's removal + /// writes nothing, and the enable writes no preference or flag and restores nothing. A publication that already held + /// its lock still finishes, but the enable then clears no cleanup mark the sign-out left for a removal that failed. + func testContactPaymentsEnableStopsWhenPubkySignsOutDuringPublication() async throws { + enum HeldPublication { + case publicBeforeLock, privateBeforeLock, publicHoldingLock + } + let cases: [(name: String, held: HeldPublication, calls: [String], writes: [String], keepsFlags: Bool)] = [ + ( + "public publication waiting for its lock", .publicBeforeLock, + ["private:publish", "public:true"], ["private:published", "private:removed", "public:removal failed"], false + ), + ( + "private publication waiting for its lock", .privateBeforeLock, + ["private:publish"], ["private:removed", "public:removal failed"], false + ), + ( + "public publication holding its lock while sign-out fails", .publicHoldingLock, + ["private:publish", "public:true"], ["private:published", "private:removal failed", "public:published"], true + ), + ] + let ownerKey = try useLocalPubkySecretKey() + let record = contactRecord(publicKey: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + let endpoint = PublicPaykitService.Endpoint( + methodId: .regtestOnchainP2wpkh, value: "bcrt1qendpoint", min: nil, max: nil, rawPayload: #"{"value":"bcrt1qendpoint"}"# + ) + + for testCase in cases { + let defaults = UserDefaults.standard + for key in [ + ContactPaymentsService.confirmedPreferenceKey, PublicPaykitService.publishingEnabledKey, + PrivatePaykitService.publishingEnabledKey, PublicPaykitService.cleanupPendingKey, + PrivatePaykitService.cleanupPendingKey, PrivatePaykitService.cacheStateKey, + ] { + defaults.removeObject(forKey: key) + } + let pubkyProfile = signedInProfile(ownerKey: ownerKey) + let contactsManager = ContactsManager(contactRecords: { [record] }) + let privatePaykit = PrivatePaykitService() + let writes = WriteLog() + let publicationOperations = PrivatePaykitService.EndpointPublicationOperations( + currentPublicKey: { ownerKey }, + linkedReceiverPaths: { _ in ([:], nil) }, + receiverPaths: { _ in [PaykitReceiverPath.wallet] }, + receiverPathSelection: { _, _ in + PrivateReceiverPathSelection( + linkableReceiverPaths: [], publishableReceiverPaths: [PaykitReceiverPath.wallet], + cleanupProtectedReceiverPaths: [], error: nil + ) + }, + ensureLink: { _, _ in }, + buildEndpoints: { _, _ in [endpoint] }, + syncPaymentLists: { _ in + writes.append("private:published") + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + } + ) + let reachedHeldPoint = expectation(description: "\(testCase.name): publication reached its held point") + let (gate, release) = AsyncStream.makeStream() + let held = testCase.held + let operations = OperationsSpy() + operations.preparePrivateEndpoints = { contactPublicKeys, requireImmediatePublication, isSessionCurrent in + if held == .privateBeforeLock { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + return await privatePaykit.syncLocalEndpointPublication( + for: contactPublicKeys, + reason: "test", + requireImmediatePublication: requireImmediatePublication, + isSessionCurrent: isSessionCurrent, + operations: publicationOperations + ) + } + operations.syncPublicEndpoints = { _, isSessionCurrent in + if held == .publicBeforeLock { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + try await PublicPaykitService.withEndpointLock(unlessSessionEnded: isSessionCurrent) { + if held == .publicHoldingLock { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + writes.append("public:published") + } + } + let enable = Task { + try await ContactPaymentsService.setEnabled( + true, + pubkyProfile: pubkyProfile, + contactsManager: contactsManager, + operations: operations.makeOperations(), + defaults: defaults + ) + } + await fulfillment(of: [reachedHeldPoint], timeout: 2) + + let privateRemovalFails = held == .publicHoldingLock + do { + try await pubkyProfile.signOut(performSessionCleanup: { @MainActor in + try await privatePaykit.withPublicationLock { + guard !privateRemovalFails else { + writes.append("private:removal failed") + throw TestError.operationFailed + } + writes.append("private:removed") + } + do { + try await PublicPaykitService.withEndpointLock { + writes.append("public:removal failed") + throw TestError.operationFailed + } + } catch { + PublicPaykitService.setCleanupPending(true) + } + }) + XCTAssertFalse(privateRemovalFails, testCase.name) + } catch { + XCTAssertTrue(privateRemovalFails, testCase.name) + } + release.finish() + let result = await enable.result + + XCTAssertNoThrow(try result.get(), testCase.name) + XCTAssertEqual(operations.calls, testCase.calls, testCase.name) + XCTAssertEqual(writes.entries, testCase.writes, testCase.name) + XCTAssertEqual(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), testCase.keepsFlags, testCase.name) + XCTAssertEqual(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), testCase.keepsFlags, testCase.name) + XCTAssertEqual(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), testCase.keepsFlags, testCase.name) + XCTAssertEqual(operations.publicCleanupValues, [], testCase.name) + XCTAssertEqual(operations.privateCleanupValues, [], testCase.name) + XCTAssertTrue(PublicPaykitService.isCleanupPending, testCase.name) + } + } + + /// A sign-out that lands after the last session check before a change's writes still stops it: the change checks the + /// session again right before it writes the preference, the flags or a cleared cleanup mark. + func testContactPaymentsChangeWritesNothingOnceThePubkySessionChanged() async throws { + let cases: [(name: String, enabled: Bool, calls: [String])] = [ + ("enable", true, []), + ("disable", false, ["private:remove", "public:false"]), + ] + + for testCase in cases { + try await withIsolatedDefaultsAsync { defaults in + let sharing = !testCase.enabled + defaults.set(sharing, forKey: ContactPaymentsService.confirmedPreferenceKey) + defaults.set(sharing, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(sharing, forKey: PrivatePaykitService.publishingEnabledKey) + let operations = OperationsSpy() + + try await ContactPaymentsService.setEnabled( + testCase.enabled, + contactPublicKeys: ["contact-a"], + canUsePrivatePayments: true, + operations: operations.makeOperations(), + defaults: defaults, + isSessionCurrent: { false } + ) + + XCTAssertEqual(operations.calls, testCase.calls, testCase.name) + XCTAssertEqual(operations.publicCleanupValues, [], testCase.name) + XCTAssertEqual(operations.privateCleanupValues, [], testCase.name) + XCTAssertEqual(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), sharing, testCase.name) + XCTAssertEqual(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), sharing, testCase.name) + XCTAssertEqual(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), sharing, testCase.name) + } + } + } + + /// Signs in with a local secret key so private contact payments are available, restoring the app's defaults, the + /// Pubky secret key and the adopted reference when the test ends. + private func useLocalPubkySecretKey() throws -> String { + snapshotAppDefaultsDomain() + let savedReference = AdoptedPubkyReference.current + let savedSecretKey = try Keychain.load(key: .pubkySecretKey) + addTeardownBlock { + AdoptedPubkyReference.current = savedReference + if let savedSecretKey { + try? Keychain.upsert(key: .pubkySecretKey, data: savedSecretKey) + } else { + try? Keychain.delete(key: .pubkySecretKey) + } + } + let secretKeyHex = String(repeating: "01", count: 32) + try Keychain.upsert(key: .pubkySecretKey, data: Data(secretKeyHex.utf8)) + let rawOwnerKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) + return rawOwnerKey.hasPrefix("pubky") ? rawOwnerKey : "pubky\(rawOwnerKey)" + } + + private func signedInProfile(ownerKey: String) -> PubkyProfileManager { + let pubkyProfile = PubkyProfileManager() + pubkyProfile.publicKey = ownerKey + pubkyProfile.authState = .authenticated + return pubkyProfile + } + + private func contactRecord(publicKey: String) -> ContactRecord { + ContactRecord( + publicKey: publicKey, receiverPaths: [PaykitReceiverPath.wallet], label: "Contact", + profile: PaykitProfile(displayName: "Contact", imageUri: nil, extraJson: nil), + profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", + publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, + publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil + ) + } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { let suiteName = "ContactPaymentsServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -357,6 +545,15 @@ final class ContactPaymentsServiceTests: XCTestCase { case operationFailed } + @MainActor + private final class WriteLog { + private(set) var entries: [String] = [] + + func append(_ entry: String) { + entries.append(entry) + } + } + private final class OperationsSpy { struct PrivatePublication { let contactPublicKeys: [String] @@ -373,18 +570,20 @@ final class ContactPaymentsServiceTests: XCTestCase { var privatePublicationFailures: Set = [] var privateRemovalFailures: Set = [] var onPreparePrivateEndpoints: (() -> Void)? - var preparePrivateEndpoints: (([String], Bool) async -> Error?)? + var preparePrivateEndpoints: (([String], Bool, @escaping ContactPaymentsService.SessionCheck) async -> Error?)? + var syncPublicEndpoints: ((Bool, @escaping ContactPaymentsService.SessionCheck) async throws -> Void)? func makeOperations() -> ContactPaymentsService.Operations { ContactPaymentsService.Operations( - syncPublicEndpoints: { publish in + syncPublicEndpoints: { publish, isSessionCurrent in self.calls.append("public:\(publish)") self.publicPublicationValues.append(publish) + try await self.syncPublicEndpoints?(publish, isSessionCurrent) if self.publicPublicationFailures.contains(self.publicPublicationValues.count) { throw TestError.operationFailed } }, - preparePrivateEndpoints: { contactPublicKeys, requiresImmediatePublication in + preparePrivateEndpoints: { contactPublicKeys, requiresImmediatePublication, isSessionCurrent in self.onPreparePrivateEndpoints?() self.calls.append("private:publish") self.privatePublications.append( @@ -394,7 +593,7 @@ final class ContactPaymentsServiceTests: XCTestCase { ) ) if let preparePrivateEndpoints = self.preparePrivateEndpoints { - return await preparePrivateEndpoints(contactPublicKeys, requiresImmediatePublication) + return await preparePrivateEndpoints(contactPublicKeys, requiresImmediatePublication, isSessionCurrent) } return self.privatePublicationFailures.contains(self.privatePublications.count) ? TestError.operationFailed : nil }, From 1450d5ce820667e01a9d987ac9ca9a26303fda54 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 09:17:42 +0200 Subject: [PATCH 64/79] test: hold only publishing calls when signing out during a contact payments publication --- BitkitTests/ContactPaymentsServiceTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index f65e4bf37..a5fb08143 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -389,7 +389,8 @@ final class ContactPaymentsServiceTests: XCTestCase { operations: publicationOperations ) } - operations.syncPublicEndpoints = { _, isSessionCurrent in + operations.syncPublicEndpoints = { publish, isSessionCurrent in + guard publish else { return } if held == .publicBeforeLock { reachedHeldPoint.fulfill() for await _ in gate {} From 0a5d47aeaac44e57b9d8bbf1f4286e2b69b000e3 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 09:19:13 +0200 Subject: [PATCH 65/79] fix: stop the Pay Contacts enable when Pubky signs out --- Bitkit/Services/ContactPaymentsService.swift | 17 ----------------- Bitkit/Views/Profile/PayContactsView.swift | 11 +++-------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index e239e2611..f64fef27a 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -59,23 +59,6 @@ enum ContactPaymentsService { defaults.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) } - @MainActor - static func setEnabled( - _ enabled: Bool, - wallet: WalletViewModel, - contactPublicKeys: [String], - canUsePrivatePayments: Bool, - defaults: UserDefaults = .standard - ) async throws { - try await setEnabled( - enabled, - contactPublicKeys: contactPublicKeys, - canUsePrivatePayments: canUsePrivatePayments, - operations: .live(wallet: wallet), - defaults: defaults - ) - } - /// Turns contact payments on or off for the signed-in Pubky session. Private endpoints are prepared for the saved /// contacts, so it first waits for the first contacts load. Once that session ends or changes, the change stops and /// returns quietly: it writes no preference, publishing flag or cleared cleanup mark, and its endpoint publications diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 1d617b462..91ab3b811 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -61,16 +61,11 @@ struct PayContactsView: View { defer { isSaving = false } do { - let canUsePrivatePayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile - if canUsePrivatePayments, let publicKey = pubkyProfile.publicKey { - try await contactsManager.loadContactsIfNeeded(for: publicKey) - } - try await ContactPaymentsService.setEnabled( true, - wallet: wallet, - contactPublicKeys: contactsManager.contacts.map(\.publicKey), - canUsePrivatePayments: canUsePrivatePayments + pubkyProfile: pubkyProfile, + contactsManager: contactsManager, + operations: .live(wallet: wallet) ) navigation.path = [.profile] } catch { From 9d0a5269b76e6ef5cce718cb153776c2f390f0dd Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 09:50:54 +0200 Subject: [PATCH 66/79] fix: keep Pay Contacts in place when the enable stops or the user leaves The session-checked contact payments change now returns whether it was applied. Pay Contacts opens Profile only for an applied enable while it is still showing, so an enable that a Pubky sign-out stopped, or one that finishes after the user left, leaves navigation alone. --- Bitkit/Services/ContactPaymentsService.swift | 16 ++++++---- Bitkit/Views/Profile/PayContactsView.swift | 13 ++++++-- BitkitTests/ContactPaymentsServiceTests.swift | 30 ++++++++++++++++--- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index f64fef27a..1ecbcdcbd 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -63,7 +63,8 @@ enum ContactPaymentsService { /// contacts, so it first waits for the first contacts load. Once that session ends or changes, the change stops and /// returns quietly: it writes no preference, publishing flag or cleared cleanup mark, and its endpoint publications /// check the session under the locks that sign-out's endpoint removal also takes, so they never write endpoints back - /// after that removal. + /// after that removal. Returns true once the change was applied, and false when it stopped for a session change. + @discardableResult @MainActor static func setEnabled( _ enabled: Bool, @@ -71,19 +72,19 @@ enum ContactPaymentsService { contactsManager: ContactsManager, operations: Operations, defaults: UserDefaults = .standard - ) async throws { - guard let session = pubkyProfile.currentSession else { return } + ) async throws -> Bool { + guard let session = pubkyProfile.currentSession else { return false } let isSessionCurrent: SessionCheck = { pubkyProfile.currentSession == session } let canUsePrivatePayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile if canUsePrivatePayments { do { try await contactsManager.loadContactsIfNeeded(for: session.publicKey) } catch { - guard isSessionCurrent() else { return } + guard isSessionCurrent() else { return false } throw error } } - guard isSessionCurrent() else { return } + guard isSessionCurrent() else { return false } do { try await setEnabled( @@ -95,9 +96,12 @@ enum ContactPaymentsService { isSessionCurrent: isSessionCurrent ) } catch { - guard isSessionCurrent() else { return } + guard isSessionCurrent() else { return false } throw error } + // The change above stops quietly once the session changes, and a changed session never compares current again, + // so this also reports that stop. + return isSessionCurrent() } /// Once `isSessionCurrent` is false, the preference, flags and cleanup marks belong to the sign-out or other session diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 91ab3b811..ece5e18e7 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -9,6 +9,13 @@ struct PayContactsView: View { @State private var isSaving = false + /// Continue's enable runs in its own task, so it can finish after the user left Pay Contacts or after a Pubky sign-out + /// stopped it. Like an import that finishes after the user left it, it opens Profile only for an applied enable while + /// Pay Contacts is still showing. + static func destinationAfterEnable(isApplied: Bool, currentRoute: Route?) -> Route? { + isApplied && currentRoute == .payContacts ? .profile : nil + } + var body: some View { VStack(spacing: 0) { NavigationBar(title: t("profile__pay_contacts_nav_title")) @@ -61,13 +68,15 @@ struct PayContactsView: View { defer { isSaving = false } do { - try await ContactPaymentsService.setEnabled( + let isApplied = try await ContactPaymentsService.setEnabled( true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: .live(wallet: wallet) ) - navigation.path = [.profile] + if let destination = Self.destinationAfterEnable(isApplied: isApplied, currentRoute: navigation.currentRoute) { + navigation.path = [destination] + } } catch { Logger.error("Failed to enable contact payments: \(error)", context: "PayContactsView") app.toast( diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index a5fb08143..0ac8e678f 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -236,7 +236,7 @@ final class ContactPaymentsServiceTests: XCTestCase { /// General Settings turns contact payments on in a task that outlives the screen, after the first contacts load. A /// Pubky sign-out during that load, finished or still running, stops the change before it writes the preference or - /// publishes anything, and a load that then fails reports nothing. + /// publishes anything, and a load that then fails reports nothing. The change returns whether it was applied. func testContactPaymentsChangeStopsWhenPubkySignsOutDuringTheContactsLoad() async throws { enum SignOut { case never, finished, running @@ -298,13 +298,13 @@ final class ContactPaymentsServiceTests: XCTestCase { } releaseLoad.finish() let result = await change.result - XCTAssertNoThrow(try result.get(), testCase.name) + let enabled = testCase.signOut == .never + XCTAssertEqual(try result.get(), enabled, testCase.name) if let runningSignOut { runningSignOut.release.finish() try await runningSignOut.task.value } - let enabled = testCase.signOut == .never XCTAssertEqual(operations.calls, enabled ? ["private:publish", "public:true"] : [], testCase.name) XCTAssertEqual(operations.privatePublications.map(\.contactPublicKeys), enabled ? [[contactKey]] : [], testCase.name) XCTAssertEqual(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), enabled, testCase.name) @@ -318,6 +318,7 @@ final class ContactPaymentsServiceTests: XCTestCase { /// an endpoint publication is in flight stops that enable: a publication that gets its lock after sign-out's removal /// writes nothing, and the enable writes no preference or flag and restores nothing. A publication that already held /// its lock still finishes, but the enable then clears no cleanup mark the sign-out left for a removal that failed. + /// Either way the enable returns that it stopped, so Pay Contacts does not open Profile. func testContactPaymentsEnableStopsWhenPubkySignsOutDuringPublication() async throws { enum HeldPublication { case publicBeforeLock, privateBeforeLock, publicHoldingLock @@ -440,7 +441,7 @@ final class ContactPaymentsServiceTests: XCTestCase { release.finish() let result = await enable.result - XCTAssertNoThrow(try result.get(), testCase.name) + XCTAssertFalse(try result.get(), testCase.name) XCTAssertEqual(operations.calls, testCase.calls, testCase.name) XCTAssertEqual(writes.entries, testCase.writes, testCase.name) XCTAssertEqual(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), testCase.keepsFlags, testCase.name) @@ -487,6 +488,27 @@ final class ContactPaymentsServiceTests: XCTestCase { } } + /// Pay Contacts' Continue enables contact payments in a task that outlives the screen. Like an import that finishes + /// after the user left it, it opens Profile only for an applied enable while Pay Contacts is still showing. + func testPayContactsOpensProfileOnlyForAnAppliedEnableWhilePayContactsIsShowing() { + let cases: [(name: String, isApplied: Bool, currentRoute: Route?, destination: Route?)] = [ + ("applied on Pay Contacts", true, .payContacts, .profile), + ("stopped by a Pubky sign-out", false, .payContacts, nil), + ("stopped after the user left", false, .settings, nil), + ("applied after the user opened another screen", true, .settings, nil), + ("applied after the user went back", true, .createProfile, nil), + ("applied after the user went back to the start", true, nil, nil), + ] + + for testCase in cases { + XCTAssertEqual( + PayContactsView.destinationAfterEnable(isApplied: testCase.isApplied, currentRoute: testCase.currentRoute), + testCase.destination, + testCase.name + ) + } + } + /// Signs in with a local secret key so private contact payments are available, restoring the app's defaults, the /// Pubky secret key and the adopted reference when the test ends. private func useLocalPubkySecretKey() throws -> String { From fd3783f499012c6bbe82d4f587a82b347386847d Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 19:14:59 +0200 Subject: [PATCH 67/79] docs: note the Profile loading screen Contacts shows while the session restores Since #864, Contacts opened while a saved session restores shows Profile's loading screen before the list. The contacts list loading journey now says to wait for the list instead of reporting that spinner. --- journeys/pubky-profile/README.md | 5 +++++ journeys/pubky-profile/contacts-list-loading.xml | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 77aa5d69f..e54f9d071 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -72,6 +72,11 @@ cached header needs initialization to finish, so wait for it to clear and do not header failing. A spinner under the "Profile" title with no name is different: it means no cached header showed. +Contacts opened while the Pubky session is still restoring after a relaunch shows the Profile +loading screen first (`ProfileLoading` under the "Profile" title) and moves on to the list by itself +once the session is back. That is the session restore, not the contacts list, so wait for the list +rather than reporting the spinner. + ## iOS vs Android - **Profile while loading.** iOS shows the cached name and avatar read-only under diff --git a/journeys/pubky-profile/contacts-list-loading.xml b/journeys/pubky-profile/contacts-list-loading.xml index 9015d4d55..8d01d0787 100644 --- a/journeys/pubky-profile/contacts-list-loading.xml +++ b/journeys/pubky-profile/contacts-list-loading.xml @@ -14,7 +14,10 @@ leaves exactly that. Use the test simulator UDID for <UDID>. A warm network can resolve every profile before the first snapshot; report "already resolved" - rather than failing. See README.md. + rather than failing. If the Pubky session is still restoring when Contacts is tapped, Contacts + shows the Profile loading screen first (id "ProfileLoading" under the "Profile" title) and moves + on to the list by itself once the session is back. That is the session restore, not the contacts + list. See README.md. Run `xcrun simctl terminate <UDID> to.bitkit` From afff078efa12763c534f50c5a9e3697331803bfa Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 19:26:34 +0200 Subject: [PATCH 68/79] fix: drop queued tag changes when the Pubky session changes A tag change on a contact's screen waits for that contact's profile lookup. If the user signed out and signed in with another identity before the lookup finished, the change then saved through the new session, which accepts a contact with the same key, and wrote back the local override that sign-out had cleared. Each tag change is now bound to the Pubky session it was queued in and to the Contacts reset generation. A reset or owner change forgets the queue, and a stale change stops before the lookup, before the save and again before the override and row are written, without saving or reporting an error. Contact edits get the same reset check around their save. --- Bitkit/Managers/ContactsManager.swift | 55 +++- Bitkit/Views/Contacts/ContactDetailView.swift | 14 +- BitkitTests/ContactsManagerTests.swift | 251 +++++++++++++++++- 3 files changed, 309 insertions(+), 11 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index b304878eb..2891e056e 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -121,7 +121,8 @@ class ContactsManager: ObservableObject { private var profileRefresh: ContactProfileRefresh? private var profileRefreshCount = 0 private var pendingProfileLookups: [String: Task] = [:] - /// The last tag change queued for each contact, which the next one for that contact waits for. + /// The last tag change queued for each contact, which the next one for that contact waits for. Forgotten on a reset + /// or owner change, so a change for the next session never waits behind one from the session before. private var tagChanges: [String: Task] = [:] /// Profiles resolved this session for the owner's contacts, shown in place of stored labels while a load refreshes. private var resolvedProfiles: [String: PubkyProfile] = [:] @@ -437,13 +438,14 @@ class ContactsManager: ObservableObject { } /// Stops every profile lookup still running for the previous owner, so none of them can fill the next owner's rows - /// or cache. + /// or cache, and drops that owner's queued tag changes, which then stop without saving. private func forgetResolvedProfiles(owner: String?) { resolvedProfilesGeneration += 1 profileRefresh?.task.cancel() profileRefresh = nil pendingProfileLookups.values.forEach { $0.cancel() } pendingProfileLookups = [:] + tagChanges = [:] resolvedProfiles = [:] resolvedProfilesOwner = owner } @@ -584,7 +586,26 @@ class ContactsManager: ObservableObject { // MARK: - Update Contact + /// A reset or owner change while the save runs stops it quietly: it writes no local override and reports no error. func updateContact(publicKey: String, name: String, bio: String, imageUrl: String?, links: [PubkyProfileLink], tags: [String]) async throws { + let generation = resolvedProfilesGeneration + try await updateContact(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags) { + self.resolvedProfilesGeneration == generation + } + } + + /// `isCurrent` is checked right before the save and again once it returns, with nothing suspending between that check + /// and the writes after it. Sign-out clears the local overrides, so a save that lands after the session changed must + /// not write one back, and its error, if any, belongs to a session the user has left. + private func updateContact( + publicKey: String, + name: String, + bio: String, + imageUrl: String?, + links: [PubkyProfileLink], + tags: [String], + isCurrent: @MainActor () -> Bool + ) async throws { let prefixedKey = ensurePubkyPrefix(publicKey) let contactData = PubkyProfileData( @@ -595,10 +616,20 @@ class ContactsManager: ObservableObject { tags: tags ) + guard isCurrent() else { return } let saveContactLabel = saveContactLabel - try await Task.detached { - try await saveContactLabel(prefixedKey, name) - }.value + do { + try await Task.detached { + try await saveContactLabel(prefixedKey, name) + }.value + } catch { + guard isCurrent() else { return } + throw error + } + guard isCurrent() else { + Logger.info("Dropped a contact update that a session change overtook", context: "ContactsManager") + return + } Self.upsertContactProfileOverride(publicKey: prefixedKey, data: contactData) let updatedProfile = contactData.toProfile(publicKey: prefixedKey) @@ -614,16 +645,27 @@ class ContactsManager: ObservableObject { /// for that contact are done, so each one applies over the tags the last one saved. It first waits for the lookup of a /// profile the row is still waiting for, so the save keeps the bio, links and avatar that lookup finds. `shownProfile` /// stands in when the contact is no longer listed. A failed change does not stop the ones queued after it. + /// + /// A change belongs to the session it was queued in. Once `isSessionCurrent` is false, or a reset or another owner's + /// load has run, it stops quietly: it saves nothing, writes no local override and reports no error. A lookup it waits + /// for can still finish after a sign-out, and the next identity may have saved a contact with the same key. func updateContactTags( publicKey: String, shownProfile: PubkyProfile, + isSessionCurrent: @escaping @MainActor () -> Bool, transform: @escaping ([String]) -> [String] ) -> Task { let key = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + let generation = resolvedProfilesGeneration + let isCurrent: @MainActor () -> Bool = { [weak self] in + self?.resolvedProfilesGeneration == generation && isSessionCurrent() + } let previousChange = tagChanges[key] let change = Task { _ = await previousChange?.result + guard isCurrent() else { return } await resolvePendingContactProfile(publicKey: publicKey) + guard isCurrent() else { return } let latest = contacts.first(where: { $0.publicKey == publicKey })?.profile ?? shownProfile try await updateContact( publicKey: publicKey, @@ -631,7 +673,8 @@ class ContactsManager: ObservableObject { bio: latest.bio, imageUrl: latest.imageUrl, links: latest.links, - tags: transform(latest.tags) + tags: transform(latest.tags), + isCurrent: isCurrent ) } tagChanges[key] = change diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index e95a076c5..3b5a9cc1a 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -7,6 +7,7 @@ struct ContactDetailView: View { @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var contactsManager: ContactsManager + @EnvironmentObject var pubkyProfile: PubkyProfileManager @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel @@ -235,11 +236,17 @@ struct ContactDetailView: View { } /// Shows the change at once, then saves it, one tag change at a time, over the contact's latest profile, after the - /// lookup of a profile the row is still waiting for. + /// lookup of a profile the row is still waiting for. The save is dropped quietly once this Pubky session ends. private func updateTags(_ transform: @escaping ([String]) -> [String]) { - guard let current = profile else { return } + let pubkyProfile = pubkyProfile + guard let current = profile, let session = pubkyProfile.currentSession else { return } profile = current.withTags(transform(current.tags)) - let change = contactsManager.updateContactTags(publicKey: publicKey, shownProfile: current, transform: transform) + let change = contactsManager.updateContactTags( + publicKey: publicKey, + shownProfile: current, + isSessionCurrent: { pubkyProfile.currentSession == session }, + transform: transform + ) Task { do { try await change.value @@ -343,6 +350,7 @@ struct ContactDetailView: View { .environmentObject(CurrencyViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(ContactsManager()) + .environmentObject(PubkyProfileManager()) .environmentObject(SettingsViewModel.shared) .environmentObject(SheetViewModel()) .environmentObject(WalletViewModel()) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index aeed68476..a584368b4 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -946,8 +946,10 @@ final class ContactsManagerTests: XCTestCase { // The user adds two tags while it is still held. let shown = try XCTUnwrap(manager.contacts.first?.profile) - let first = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown) { $0 + ["friend"] } - let second = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown.withTags(["friend"])) { $0 + ["work"] } + let first = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown, isSessionCurrent: { true }) { $0 + ["friend"] } + let second = manager.updateContactTags( + publicKey: contactProfileKey, shownProfile: shown.withTags(["friend"]), isSessionCurrent: { true } + ) { $0 + ["work"] } await interactive.release() await screenLookup.value try await first.value @@ -967,6 +969,194 @@ final class ContactsManagerTests: XCTestCase { await manager.waitForProfileRefreshForTesting() } + /// The review regression: two tag changes wait for the contact's held lookup, the user signs out and signs in with + /// another identity, and the lookup finishes only then. The next identity may have saved a contact with the same key, + /// which the SDK would accept a save for, or not, which it rejects. + func testTagChangesQueuedBeforeASignOutSaveNothingForTheNextIdentity() async throws { + let savedOverrides = ContactsManager.backupContactProfileOverrides() + addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } + + for nextIdentityHasContact in [true, false] { + let message = nextIdentityHasContact ? "when the next identity saved the same contact" : "when it has no such contact" + ContactsManager.restoreContactProfileOverrides(nil) + let session = TestPubkySession() + let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) + await interactive.hold() + let manager = ContactsManager( + fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, + saveContactLabel: { try await store.save($0, label: $1) } + ) + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner-a", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + + let shown = try XCTUnwrap(manager.contacts.first?.profile) + let first = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown, isSessionCurrent: session.check()) { + $0 + ["friend"] + } + let second = manager.updateContactTags( + publicKey: contactProfileKey, shownProfile: shown.withTags(["friend"]), isSessionCurrent: session.check() + ) { $0 + ["work"] } + // The first change takes the contact's lookup over, and the lookup is held. + while await interactive.heldCount < 1 { + await Task.yield() + } + + // Signing out ends the session, AppScene resets Contacts, and the sign-out clears the overrides. + session.change() + manager.reset() + ContactsManager.restoreContactProfileOverrides(nil) + // Another identity signs in and loads its contacts, whose own lookups are held. + session.change() + await store.signIn(savedKeys: nextIdentityHasContact ? [contactProfileKey] : []) + let nextRecords = nextIdentityHasContact ? [unprofiledRecord(key: contactProfileKey, label: "Bob's label")] : [] + let nextBulk = HeldProfileLookups(profiles: [:]) + await nextBulk.hold() + try await manager.loadContacts(for: "owner-b", fetchContactRecords: { nextRecords }, fetchRemoteProfile: { try await nextBulk.fetch($0) }) + + // The cancelled read finishes anyway. + await interactive.release() + do { + try await first.value + try await second.value + } catch { + XCTFail("A tag change from before the sign-out must not report an error, so no toast shows, \(message): \(error)") + } + + let saved = await store.savedLabels + XCTAssertEqual(saved, [], "Neither change saves through the next identity's session, \(message)") + XCTAssertNil(ContactsManager.backupContactProfileOverrides(), "Neither change writes back an override, \(message)") + let lookups = await interactive.fetchedKeys + XCTAssertEqual(lookups, [contactProfileKey], "The second change does not look the next identity's contact up, \(message)") + XCTAssertEqual(manager.contacts.map(\.displayName), nextIdentityHasContact ? ["Bob's label"] : [], message) + XCTAssertEqual(manager.contacts.map(\.profile.tags), nextIdentityHasContact ? [[]] : [], message) + + await bulk.release() + await nextBulk.release() + await manager.waitForProfileRefreshForTesting() + } + } + + /// Covers each half of a session change on its own: the sign-out starting before AppScene resets Contacts, and a reset + /// with the same identity signing straight back in, whose own tag change must not wait behind the old one. + func testQueuedTagChangeStopsOnceItsSessionEndsOrContactsReset() async throws { + let savedOverrides = ContactsManager.backupContactProfileOverrides() + addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } + + for resetsContacts in [false, true] { + let message = resetsContacts ? "after a reset" : "once the session ends" + ContactsManager.restoreContactProfileOverrides(nil) + let session = TestPubkySession() + let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) + await interactive.hold() + let manager = ContactsManager( + fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, + saveContactLabel: { try await store.save($0, label: $1) } + ) + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + + let shown = try XCTUnwrap(manager.contacts.first?.profile) + let stale = manager.updateContactTags( + publicKey: contactProfileKey, + shownProfile: shown, + isSessionCurrent: resetsContacts ? { true } : session.check() + ) { $0 + ["friend"] } + while await interactive.heldCount < 1 { + await Task.yield() + } + + var nextChange: Task? + var expectedLabels: [String] = [] + var expectedTags: [String]? + if resetsContacts { + manager.reset() + let nextRecords = [contactRecord(key: contactProfileKey, name: "Alice")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { nextRecords }, fetchRemoteProfile: { _ in nil }) + let next = try XCTUnwrap(manager.contacts.first?.profile) + let change = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: next, isSessionCurrent: { true }) { + $0 + ["new"] + } + let nextSaved = expectation(description: "The next session's change saves while the old lookup is still held") + Task { + _ = await change.result + nextSaved.fulfill() + } + await fulfillment(of: [nextSaved], timeout: 2) + nextChange = change + expectedLabels = ["Alice"] + expectedTags = ["new"] + } else { + session.change() + } + + await interactive.release() + do { + try await stale.value + } catch { + XCTFail("A stale tag change must not report an error \(message): \(error)") + } + try await nextChange?.value + + let saved = await store.savedLabels + XCTAssertEqual(saved, expectedLabels, "The stale change saves nothing \(message)") + XCTAssertEqual(ContactsManager.backupContactProfileOverrides()?[contactProfileKey]?.tags, expectedTags, message) + + await bulk.release() + await manager.waitForProfileRefreshForTesting() + } + } + + /// The save itself was admitted, so it runs, but a sign-out and another identity's sign-in land before it returns. The + /// save may then succeed or fail; either way nothing is published for the next identity. + func testTagSaveThatASessionChangeOvertakesPublishesNothing() async throws { + let savedOverrides = ContactsManager.backupContactProfileOverrides() + addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } + + for heldSaveSucceeds in [true, false] { + let message = heldSaveSucceeds ? "after a save that succeeds" : "after a save that fails" + ContactsManager.restoreContactProfileOverrides(nil) + let session = TestPubkySession() + let store = SignedInContactStore(savedKeys: [contactProfileKey]) + await store.hold() + let manager = ContactsManager(saveContactLabel: { try await store.save($0, label: $1) }) + let records = [contactRecord(key: contactProfileKey, name: "Alice")] + try await manager.loadContacts(for: "owner-a", fetchContactRecords: { records }, fetchRemoteProfile: { _ in nil }) + + let shown = try XCTUnwrap(manager.contacts.first?.profile) + let change = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown, isSessionCurrent: session.check()) { + $0 + ["friend"] + } + while await store.heldCount < 1 { + await Task.yield() + } + + session.change() + manager.reset() + ContactsManager.restoreContactProfileOverrides(nil) + session.change() + await store.signIn(savedKeys: heldSaveSucceeds ? [contactProfileKey] : []) + let nextRecords = [contactRecord(key: contactProfileKey, name: "Bob's Alice")] + try await manager.loadContacts(for: "owner-b", fetchContactRecords: { nextRecords }, fetchRemoteProfile: { _ in nil }) + + await store.release() + do { + try await change.value + } catch { + XCTFail("A save the sign-out overtook must not report an error \(message): \(error)") + } + + XCTAssertNil(ContactsManager.backupContactProfileOverrides(), "No override is written back \(message)") + XCTAssertEqual(manager.contacts.map(\.displayName), ["Bob's Alice"], "The next identity's row is unchanged \(message)") + XCTAssertEqual(manager.contacts.map(\.profile.tags), [[]], message) + } + } + func testPreparingAnImportLooksFollowsUpOnTheInteractiveLane() async throws { let lanes = ProfileLookupLanes() let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] @@ -1308,6 +1498,63 @@ private actor ContactProfileFetchStub { } } +/// Stands in for `PubkyProfileManager.currentSession`: every sign-in and sign-out starts a new session. +@MainActor +private final class TestPubkySession { + private var revision = 0 + + func change() { + revision += 1 + } + + /// True while the session that was current when this was called still is, as `ContactDetailView` builds it. + func check() -> @MainActor () -> Bool { + let started = revision + return { self.revision == started } + } +} + +private struct ContactNotSavedError: Error {} + +/// Stands in for the SDK's contact storage of whichever identity is signed in. Like the SDK, it saves a label only for a +/// contact that identity has saved. It can hold saves until released. +private actor SignedInContactStore { + private var savedKeys: Set + private(set) var savedLabels: [String] = [] + private var isHolding = false + private var held: [CheckedContinuation] = [] + + var heldCount: Int { + held.count + } + + init(savedKeys: Set) { + self.savedKeys = savedKeys + } + + func signIn(savedKeys: Set) { + self.savedKeys = savedKeys + } + + func hold() { + isHolding = true + } + + func release() { + isHolding = false + held.forEach { $0.resume() } + held.removeAll() + } + + func save(_ publicKey: String, label: String) async throws { + if isHolding { + await withCheckedContinuation { held.append($0) } + } + guard savedKeys.contains(publicKey) else { throw ContactNotSavedError() } + savedLabels.append(label) + } +} + /// Records the label of each contact save. private actor SavedContactLabels { private(set) var labels: [String] = [] From 67d54aae9efba6b3f0577c01e153b4fe6f6c3808 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 19:47:43 +0200 Subject: [PATCH 69/79] fix: announce saved contact changes only when the saved keys change Every Contacts load reassigned the contacts and announced a saved contacts change, and an import announced twice. Each announcement starts a private Paykit walk over every saved contact behind the publication lock, so Contacts visits queued walks that Delete Profile then waited behind. The change is now announced once per change to the set of saved contact keys, and on the first load for each owner. --- Bitkit/Managers/ContactsManager.swift | 24 +++++++--- BitkitTests/ContactsManagerTests.swift | 63 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 2891e056e..8734dc324 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -154,18 +154,29 @@ class ContactsManager: ObservableObject { didSet { guard !isApplyingProfileRefresh else { return } contactsRevision += 1 - savedContactsChangedSubject.send(contacts) + announceSavedContactsIfKeysChanged() } } private let savedContactsChangedSubject = PassthroughSubject<[PubkyContact], Never>() + /// The saved contact keys last announced, or nil while nothing was announced for the current owner. + private var announcedSavedContactKeys: Set? - /// The contacts after every change that may have changed the saved contact list. Profiles a background refresh - /// fills in do not emit, so work that runs per saved contact, such as private Paykit sync, does not rerun per row. + /// The contacts each time the set of saved contact keys changes, and on the first load for each owner even when it + /// finds none. A reload, a profile refresh or an edit that keeps the same keys does not emit, so work that runs per + /// saved contact, such as private Paykit sync, which walks every saved contact under the publication lock, does not + /// rerun each time Contacts opens. var savedContactsChangedPublisher: AnyPublisher<[PubkyContact], Never> { savedContactsChangedSubject.eraseToAnyPublisher() } + private func announceSavedContactsIfKeysChanged() { + let keys = Set(contacts.map { PubkyPublicKeyFormat.normalized($0.publicKey) ?? $0.publicKey }) + guard keys != announcedSavedContactKeys else { return } + announcedSavedContactKeys = keys + savedContactsChangedSubject.send(contacts) + } + @Published var isLoading = false @Published var hasLoaded = false @Published var loadErrorMessage: String? @@ -438,8 +449,10 @@ class ContactsManager: ObservableObject { } /// Stops every profile lookup still running for the previous owner, so none of them can fill the next owner's rows - /// or cache, and drops that owner's queued tag changes, which then stop without saving. + /// or cache, and drops that owner's queued tag changes, which then stop without saving. The next change to the + /// contacts is announced as a saved contacts change whatever keys it holds, as it belongs to another session. private func forgetResolvedProfiles(owner: String?) { + announcedSavedContactKeys = nil resolvedProfilesGeneration += 1 profileRefresh?.task.cancel() profileRefresh = nil @@ -576,8 +589,7 @@ class ContactsManager: ObservableObject { rememberResolvedProfile(contact.profile, for: contact.publicKey) } let currentKeys = Set(contacts.map(\.publicKey)) - contacts.append(contentsOf: imported.filter { !currentKeys.contains($0.publicKey) }) - contacts.sort(by: Self.isOrderedByName) + contacts = (contacts + imported.filter { !currentKeys.contains($0.publicKey) }).sorted(by: Self.isOrderedByName) Logger.info("Imported \(imported.count) new contacts", context: "ContactsManager") if let firstError { throw firstError diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index a584368b4..5a3b44ad5 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -678,6 +678,69 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(changes.last, [contactProfileKey, unresolvedFollowKey, addedKey]) } + /// Each announcement starts a private Paykit walk over every saved contact under the publication lock, so a Contacts + /// visit that announced its unchanged list queued a full walk that a later Delete Profile waited behind. + func testSavedContactsChangeIsAnnouncedOnlyWhenTheSavedKeysChange() async throws { + let manager = ContactsManager() + var changes: [Set] = [] + let subscription = manager.savedContactsChangedPublisher.sink { changes.append(Set($0.map(\.publicKey))) } + defer { subscription.cancel() } + let addedKey = "pubky" + String(repeating: "r", count: 52) + let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice", unresolvedFollowKey: "Bob", addedKey: "Carol"]) + let first = unprofiledRecord(key: contactProfileKey, label: "Label only") + let second = unprofiledRecord(key: unresolvedFollowKey, label: nil) + let added = unprofiledRecord(key: addedKey, label: nil) + let load: @MainActor ([ContactRecord]) async throws -> Void = { records in + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + await manager.waitForProfileRefreshForTesting() + } + + try await load([first, second]) + try await load([first, second]) + try await load([second, first]) + XCTAssertEqual(changes, [[contactProfileKey, unresolvedFollowKey]], "Reloads and profile refreshes with the same keys announce nothing") + + try await load([first, second, added]) + XCTAssertEqual(changes.count, 2, "Adding a key announces once") + XCTAssertEqual(changes.last, [contactProfileKey, unresolvedFollowKey, addedKey]) + + try await load([first, added]) + XCTAssertEqual(changes.count, 3, "Removing a key announces once") + XCTAssertEqual(changes.last, [contactProfileKey, addedKey]) + + manager.reset() + XCTAssertEqual(changes.last, [], "A reset empties the saved contacts") + try await load([first, added]) + XCTAssertEqual(changes.count, 5, "The first load after a reset announces even the keys announced before it") + XCTAssertEqual(changes.last, [contactProfileKey, addedKey]) + + try await manager.loadContacts( + for: "another-owner", + fetchContactRecords: { [first, added] }, + fetchRemoteProfile: { try await lookups.fetch($0) } + ) + await manager.waitForProfileRefreshForTesting() + XCTAssertEqual(changes.count, 6, "Another owner's first load announces even the same keys") + } + + func testImportAnnouncesTheSavedContactsChangeOnce() async throws { + let manager = ContactsManager() + var changes: [Set] = [] + let subscription = manager.savedContactsChangedPublisher.sink { changes.append(Set($0.map(\.publicKey))) } + defer { subscription.cancel() } + let saved = contactRecord(key: contactProfileKey, name: "Saved") + try await manager.loadContacts(for: "owner", fetchContactRecords: { [saved] }, fetchRemoteProfile: { _ in nil }) + XCTAssertEqual(changes.count, 1) + + let imported = ["e", "j", "k"].map { makeContact(publicKey: "pubky" + String(repeating: $0, count: 52)) } + try await manager.importContacts(contacts: imported) { _, _ in } + XCTAssertEqual(changes.count, 2, "An import announces its new contacts once") + XCTAssertEqual(changes.last, Set([contactProfileKey] + imported.map(\.publicKey))) + + try await manager.importContacts(contacts: imported) { _, _ in } + XCTAssertEqual(changes.count, 2, "An import that adds nothing announces nothing") + } + func testReloadThatPublishesNothingLeavesTheRunningProfileRefreshToFinish() async throws { let manager = ContactsManager() let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) From 92f5f20c3358ac86dedfe2a712d98054266beab7 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 19:52:39 +0200 Subject: [PATCH 70/79] fix: skip contact lookups for profiles resolved in the last ten minutes A Contacts load no longer looks up again every contact whose profile this session already resolved, so returning from a contact's screen does not re-resolve the whole list. Contacts without a resolved profile are still looked up on every load, a placeholder is no longer remembered as a resolved profile, and a reset or an owner change clears the state. --- Bitkit/Managers/ContactsManager.swift | 44 ++++++++--- BitkitTests/ContactsManagerTests.swift | 74 +++++++++++++++++++ journeys/pubky-profile/README.md | 7 +- .../pubky-profile/contacts-list-loading.xml | 7 +- 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 8734dc324..9dbf241c3 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -115,6 +115,9 @@ struct ContactSection: Identifiable { @MainActor class ContactsManager: ObservableObject { + /// How long a contact profile resolved in this session is shown without being looked up again. + nonisolated static let contactProfileFreshness: TimeInterval = 10 * 60 + private var contactsRevision = 0 private var loadGeneration = 0 private var isApplyingProfileRefresh = false @@ -124,14 +127,16 @@ class ContactsManager: ObservableObject { /// The last tag change queued for each contact, which the next one for that contact waits for. Forgotten on a reset /// or owner change, so a change for the next session never waits behind one from the session before. private var tagChanges: [String: Task] = [:] - /// Profiles resolved this session for the owner's contacts, shown in place of stored labels while a load refreshes. - private var resolvedProfiles: [String: PubkyProfile] = [:] + /// Profiles resolved this session for the owner's contacts, shown in place of stored labels while a load refreshes, + /// each with the time it was resolved. + private var resolvedProfiles: [String: ResolvedContactProfile] = [:] private var resolvedProfilesOwner: String? private var resolvedProfilesGeneration = 0 private let contactRecords: @Sendable () async throws -> [ContactRecord] private let fetchFollows: @Sendable (String) async throws -> [String] private let fetchRemoteProfile: @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? private let saveContactLabel: @Sendable (_ publicKey: String, _ label: String) async throws -> Void + private let currentDate: @Sendable () -> Date init( contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords, @@ -141,12 +146,14 @@ class ContactsManager: ObservableObject { }, saveContactLabel: @escaping @Sendable (_ publicKey: String, _ label: String) async throws -> Void = { _ = try await PubkyService.saveContact(publicKey: $0, label: $1) - } + }, + currentDate: @escaping @Sendable () -> Date = { Date() } ) { self.contactRecords = contactRecords self.fetchFollows = fetchFollows self.fetchRemoteProfile = fetchRemoteProfile self.saveContactLabel = saveContactLabel + self.currentDate = currentDate } /// Profile refreshes replace rows without counting as a change to the saved contacts. @@ -249,8 +256,10 @@ class ContactsManager: ObservableObject { } /// Publishes the saved records straight away, each with the best profile already known, then looks the remaining - /// profiles up in the background on the bulk read lane and updates rows as they resolve. A failed lookup leaves its - /// row as it is. A cancelled or failed load publishes nothing and leaves a running refresh to finish. A cancelled + /// profiles up in the background on the bulk read lane and updates rows as they resolve. A profile resolved in this + /// session less than `contactProfileFreshness` ago is not looked up again, so coming back to Contacts does not look + /// every contact up again. A failed lookup leaves its row as it is and remembers nothing, so the next load looks that + /// contact up again. A cancelled or failed load publishes nothing and leaves a running refresh to finish. A cancelled /// load returns without an error even when the record read throws once cancelled, as the SDK lock does. func loadContacts( for publicKey: String, @@ -288,8 +297,12 @@ class ContactsManager: ObservableObject { let overrides = Self.loadContactProfileOverrides() contacts = records.map { savedContact(from: $0, overrides: overrides) }.sorted(by: Self.isOrderedByName) hasLoaded = true + let now = currentDate() refreshContactProfiles( - for: records.filter { $0.profile == nil && overrides[Self.contactKey(for: $0)] == nil }, + for: records.filter { record in + let key = Self.contactKey(for: record) + return record.profile == nil && overrides[key] == nil && resolvedProfiles[key]?.isFresh(at: now) != true + }, fetchRemoteProfile: fetchRemoteProfile ) await PrivatePaykitService.shared @@ -329,7 +342,7 @@ class ContactsManager: ObservableObject { profile: PubkyProfile(publicKey: publicKey, paykitProfile: profile).withNameFallback(record.label) ) } - if let profile = resolvedProfiles[publicKey] { + if let profile = resolvedProfiles[publicKey]?.profile { return PubkyContact(publicKey: publicKey, profile: profile.withNameFallback(record.label)) } let label = record.label.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } @@ -463,9 +476,10 @@ class ContactsManager: ObservableObject { resolvedProfilesOwner = owner } + /// A placeholder is not remembered, so a contact whose lookup found nothing is still looked up on the next load. private func rememberResolvedProfile(_ profile: PubkyProfile, for publicKey: String) { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } - resolvedProfiles[normalizedKey] = profile + guard !profile.isPlaceholder, let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + resolvedProfiles[normalizedKey] = ResolvedContactProfile(profile: profile, resolvedAt: currentDate()) } #if DEBUG @@ -585,7 +599,7 @@ class ContactsManager: ObservableObject { Logger.info("Stopped a contact import that a reset overtook after \(imported.count) saves", context: "ContactsManager") return } - for contact in imported where !contact.profile.isPlaceholder { + for contact in imported { rememberResolvedProfile(contact.profile, for: contact.publicKey) } let currentKeys = Set(contacts.map(\.publicKey)) @@ -858,6 +872,16 @@ class ContactsManager: ObservableObject { } } + private struct ResolvedContactProfile { + let profile: PubkyProfile + let resolvedAt: Date + + func isFresh(at now: Date) -> Bool { + let age = now.timeIntervalSince(resolvedAt) + return age >= 0 && age < ContactsManager.contactProfileFreshness + } + } + /// A background refresh of saved contacts' profiles. `labels` maps each contact it looks up to its saved label, and /// `pendingKeys` holds those whose lookup has not finished and that no screen's lookup has taken over, whose rows /// may still show only that label. diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 5a3b44ad5..bdd977d17 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -799,6 +799,62 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(fetchedKeys, [contactProfileKey], "Reopening Contacts must not look the same contact up again") } + /// Contacts used to look every saved contact up again on each load, so returning from a contact's screen re-resolved + /// the whole list. A contact still without a resolved profile is looked up on every load. + func testReloadLooksUpOnlyContactsWithoutAProfileResolvedInTheLastTenMinutes() async throws { + let clock = TestClock() + let manager = ContactsManager(currentDate: { clock.now() }) + let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only"), unprofiledRecord(key: unresolvedFollowKey, label: "No profile")] + let load: @MainActor () async throws -> Void = { + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + await manager.waitForProfileRefreshForTesting() + } + let lookupCounts: () async -> [String: Int] = { + await lookups.fetchedKeys.reduce(into: [:]) { counts, key in counts[key, default: 0] += 1 } + } + + try await load() + var counts = await lookupCounts() + XCTAssertEqual(counts, [contactProfileKey: 1, unresolvedFollowKey: 1]) + + clock.advance(by: ContactsManager.contactProfileFreshness - 1) + try await load() + counts = await lookupCounts() + XCTAssertEqual(counts, [contactProfileKey: 1, unresolvedFollowKey: 2], "Only the contact without a resolved profile is looked up again") + XCTAssertEqual(manager.contacts.map(\.displayName), ["Alice", "No profile"]) + + clock.advance(by: 1) + try await load() + counts = await lookupCounts() + XCTAssertEqual(counts, [contactProfileKey: 2, unresolvedFollowKey: 3], "A profile resolved ten minutes ago is looked up again") + } + + func testResetForgetsResolvedProfilesSoTheNextLoadLooksThemUpAgain() async throws { + let clock = TestClock() + let manager = ContactsManager(currentDate: { clock.now() }) + let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + let load: @MainActor (String) async throws -> Void = { owner in + try await manager.loadContacts(for: owner, fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + await manager.waitForProfileRefreshForTesting() + } + + try await load("owner") + try await load("owner") + var fetchedKeys = await lookups.fetchedKeys + XCTAssertEqual(fetchedKeys, [contactProfileKey], "A profile resolved moments ago is not looked up again") + + manager.reset() + try await load("owner") + fetchedKeys = await lookups.fetchedKeys + XCTAssertEqual(fetchedKeys.count, 2, "A reset, as on sign-out, forgets the profiles resolved before it") + + try await load("another-owner") + fetchedKeys = await lookups.fetchedKeys + XCTAssertEqual(fetchedKeys.count, 3, "Another owner's load does not use the profiles resolved for the first owner") + } + func testOnlyALabelOnlyContactIsLookedUpOnTheInteractiveLaneAndOnlyOnce() async throws { let labelOnly = Bitkit.PubkyProfile.forDisplay(publicKey: contactProfileKey, name: "Label only", imageUrl: nil) let cases: [(name: String, outcome: Result, row: Bitkit.PubkyProfile, names: [String])] = [ @@ -1534,6 +1590,24 @@ private actor HeldProfileLookups { } } +/// A clock the test moves by hand, for the time a contact profile was resolved. +private final class TestClock: @unchecked Sendable { + private let lock = NSLock() + private var current = Date(timeIntervalSince1970: 1_790_000_000) + + func now() -> Date { + lock.lock() + defer { lock.unlock() } + return current + } + + func advance(by interval: TimeInterval) { + lock.lock() + current += interval + lock.unlock() + } +} + /// Records the read lane of each profile lookup, answering for `contactProfileKey` and failing for any other key. private actor ProfileLookupLanes { private(set) var priorities: [PaykitPublicReadPriority] = [] diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index e54f9d071..493ef48d6 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -19,9 +19,10 @@ contacts itself is covered by [`journeys/contacts`](../contacts/README.md). Wher finishes after you left does not take you to Pay Contacts. Contacts then lists every follow. - `contacts-list-loading.xml` relaunches and opens Contacts. The saved contacts show straight away under the names they were saved with, and each fills in its profile name and avatar as its lookup - finishes. Reopening Contacts in the same session shows the profiles already found straight away. - Opening a contact whose profile has not loaded yet looks it up at once, and its edit form shows - the published bio. + finishes. Reopening Contacts in the same session shows the profiles already found straight away, + and does not look up again a profile found less than ten minutes ago, so only contacts still + without a profile get a new lookup. Opening a contact whose profile has not loaded yet looks it up + at once, and its edit form shows the published bio. ## Preconditions diff --git a/journeys/pubky-profile/contacts-list-loading.xml b/journeys/pubky-profile/contacts-list-loading.xml index 8d01d0787..831f2f080 100644 --- a/journeys/pubky-profile/contacts-list-loading.xml +++ b/journeys/pubky-profile/contacts-list-loading.xml @@ -4,9 +4,10 @@ saved with, and fills in each contact's profile name and avatar as its lookup finishes. It no longer shows a spinner over an empty list until every lookup has finished, and a lookup that fails leaves the row as it is. Within the session, reopening Contacts shows the profiles already - found straight away. Opening a contact whose profile has not loaded yet looks it up at once, and - its edit form shows the contact's published bio rather than an empty one, so saving an edit keeps - it. + found straight away, and a profile found less than ten minutes ago is not looked up again, so only + contacts still without a profile get a new lookup. Opening a contact whose profile has not loaded + yet looks it up at once, and its edit form shows the contact's published bio rather than an empty + one, so saving an edit keeps it. Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Pubky identity with at least five saved contacts, at least one of them with a published profile name and bio and one From 56ec0b6f3717c70f9dcb4f7ea6491196fc782986 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 20:02:17 +0200 Subject: [PATCH 71/79] fix: apply contact profile refresh results in batches A background refresh sorted and published the whole contact list once per resolved profile, even when the row already showed that profile. A profile that matches its row is no longer published, and the rest are applied at most once every 300 ms, sorting the list once per batch, with the last batch applied as soon as the refresh finishes. A batch overtaken by a reset, a sign-out or another owner's load is dropped, and a contact screen applies a profile still waiting for its batch at once. --- Bitkit/Managers/ContactsManager.swift | 96 +++++++++--- BitkitTests/ContactsManagerTests.swift | 144 ++++++++++++++++++ journeys/pubky-profile/README.md | 5 +- .../pubky-profile/contacts-list-loading.xml | 7 +- 4 files changed, 229 insertions(+), 23 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 9dbf241c3..603c822d8 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -117,6 +117,8 @@ struct ContactSection: Identifiable { class ContactsManager: ObservableObject { /// How long a contact profile resolved in this session is shown without being looked up again. nonisolated static let contactProfileFreshness: TimeInterval = 10 * 60 + /// Shortest time between two contact list updates of a background profile refresh. + nonisolated static let contactRefreshBatchWindow: Duration = .milliseconds(300) private var contactsRevision = 0 private var loadGeneration = 0 @@ -350,7 +352,9 @@ class ContactsManager: ObservableObject { } /// Keeps a running refresh that already looks up every one of `records`, so reopening Contacts does not start the same - /// lookups again; otherwise replaces it. + /// lookups again; otherwise replaces it. The refresh applies the profiles it finds in batches, at most once every + /// `contactRefreshBatchWindow`, sorting and publishing the list once per batch rather than once per contact, and + /// applies the last batch as soon as its lookups finish. private func refreshContactProfiles( for records: [Paykit.ContactRecord], fetchRemoteProfile: @escaping @Sendable (String) async throws -> PubkyProfile? @@ -359,8 +363,7 @@ class ContactsManager: ObservableObject { if let running = profileRefresh, Set(running.labels.keys).isSuperset(of: labels.keys) { return } - profileRefresh?.task.cancel() - profileRefresh = nil + stopProfileRefresh() guard !labels.isEmpty else { return } profileRefreshCount += 1 @@ -378,20 +381,41 @@ class ContactsManager: ObservableObject { self?.finishRefreshLookup(of: result.publicKey, profile: result.profile, refreshID: refreshID) } } + self?.applyBatchedProfiles(of: refreshID) self?.finishProfileRefresh(refreshID) } profileRefresh = ContactProfileRefresh(id: refreshID, labels: labels, pendingKeys: Set(labels.keys), task: task) } /// Drops the result for a contact whose lookup a screen took over. A task group cannot cancel one of its lookups, so - /// that contact's read still runs. + /// that contact's read still runs. Any other profile found joins the next batch. private func finishRefreshLookup(of publicKey: String, profile: PubkyProfile?, refreshID: Int) { - guard profileRefresh?.id == refreshID, profileRefresh?.pendingKeys.remove(publicKey) != nil else { return } - if let profile { - applyResolvedProfile(profile, for: publicKey) + guard profileRefresh?.id == refreshID, profileRefresh?.pendingKeys.remove(publicKey) != nil, let profile else { return } + rememberResolvedProfile(profile, for: publicKey) + profileRefresh?.batchedProfiles[publicKey] = profile + guard profileRefresh?.batchFlush == nil else { return } + profileRefresh?.batchFlush = Task { [weak self] in + try? await Task.sleep(for: Self.contactRefreshBatchWindow) + guard !Task.isCancelled else { return } + self?.applyBatchedProfiles(of: refreshID) } } + /// Applies the profiles the refresh found since its last update. A batch of a refresh that was replaced or stopped, + /// as by a reset, a sign-out or another owner's load, is dropped with it and never reaches the rows. + private func applyBatchedProfiles(of refreshID: Int) { + guard let refresh = profileRefresh, refresh.id == refreshID else { return } + refresh.batchFlush?.cancel() + profileRefresh?.batchFlush = nil + profileRefresh?.batchedProfiles = [:] + applyResolvedProfiles(refresh.batchedProfiles) + } + + private func stopProfileRefresh() { + profileRefresh?.cancel() + profileRefresh = nil + } + private func finishProfileRefresh(_ refreshID: Int) { if profileRefresh?.id == refreshID { profileRefresh = nil @@ -403,7 +427,8 @@ class ContactsManager: ObservableObject { /// reads and an edit made there keeps the contact's avatar, bio and links. The lookup takes the contact over from the /// running background refresh, which then drops its own result for it, so that refresh cannot change the row under an /// edit. Returns at once for any other row, and joins a lookup already running for the contact. When the lookup fails, - /// the row keeps its label. + /// the row keeps its label. A profile the refresh already found for the contact but holds for its next batch is + /// applied at once instead. func resolvePendingContactProfile(publicKey: String) async { await resolvePendingContactProfile(publicKey: publicKey, fetchRemoteProfile: remoteProfileLookup(on: .interactive)) } @@ -413,6 +438,9 @@ class ContactsManager: ObservableObject { fetchRemoteProfile: @escaping @Sendable (String) async throws -> PubkyProfile? ) async { guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { return } + if let refresh = profileRefresh, refresh.batchedProfiles[key] != nil { + applyBatchedProfiles(of: refresh.id) + } if let lookup = pendingProfileLookups[key] { return await lookup.value } @@ -437,18 +465,29 @@ class ContactsManager: ObservableObject { guard generation == resolvedProfilesGeneration else { return } pendingProfileLookups[publicKey] = nil if let profile { - applyResolvedProfile(profile, for: publicKey) + rememberResolvedProfile(profile, for: publicKey) + applyResolvedProfiles([publicKey: profile]) } } - private func applyResolvedProfile(_ profile: PubkyProfile, for publicKey: String) { - rememberResolvedProfile(profile, for: publicKey) - guard Self.loadContactProfileOverrides()[publicKey] == nil, - let index = contacts.firstIndex(where: { $0.publicKey == publicKey }) - else { return } - + /// Replaces the rows of `profiles` in one sorted publish. A row the user edited keeps the edit, and when every row + /// already shows its profile nothing is published. + private func applyResolvedProfiles(_ profiles: [String: PubkyProfile]) { + guard !profiles.isEmpty else { return } + let overrides = Self.loadContactProfileOverrides() var refreshed = contacts - refreshed[index] = PubkyContact(publicKey: publicKey, profile: profile) + var hasChanges = false + for index in refreshed.indices { + let publicKey = refreshed[index].publicKey + guard let profile = profiles[publicKey], + overrides[publicKey] == nil, + !refreshed[index].profile.hasSameContent(as: profile) + else { continue } + refreshed[index] = PubkyContact(publicKey: publicKey, profile: profile) + hasChanges = true + } + guard hasChanges else { return } + refreshed.sort(by: Self.isOrderedByName) isApplyingProfileRefresh = true contacts = refreshed @@ -467,8 +506,7 @@ class ContactsManager: ObservableObject { private func forgetResolvedProfiles(owner: String?) { announcedSavedContactKeys = nil resolvedProfilesGeneration += 1 - profileRefresh?.task.cancel() - profileRefresh = nil + stopProfileRefresh() pendingProfileLookups.values.forEach { $0.cancel() } pendingProfileLookups = [:] tagChanges = [:] @@ -486,6 +524,10 @@ class ContactsManager: ObservableObject { func waitForProfileRefreshForTesting() async { await profileRefresh?.task.value } + + var batchedProfileCountForTesting: Int { + profileRefresh?.batchedProfiles.count ?? 0 + } #endif // MARK: - Add Contact @@ -890,6 +932,14 @@ class ContactsManager: ObservableObject { let labels: [String: String?] var pendingKeys: Set let task: Task + /// Profiles found and not applied to the rows yet, applied together once `batchFlush` fires or the refresh ends. + var batchedProfiles: [String: PubkyProfile] = [:] + var batchFlush: Task? + + func cancel() { + task.cancel() + batchFlush?.cancel() + } } // MARK: - Contact Profile Resolution @@ -1128,3 +1178,13 @@ class ContactsManager: ObservableObject { || (normalized.contains("fetch failed") && normalized.contains("not found")) } } + +private extension PubkyProfile { + /// Compares everything a contact's row and screen show. A `PubkyProfileLink` gets a new id each time one is made, so + /// links compare by label and URL. + func hasSameContent(as other: PubkyProfile) -> Bool { + publicKey == other.publicKey && name == other.name && bio == other.bio && imageUrl == other.imageUrl + && tags == other.tags && status == other.status + && links.map(\.label) == other.links.map(\.label) && links.map(\.url) == other.links.map(\.url) + } +} diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index bdd977d17..44e619897 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -830,6 +830,117 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(counts, [contactProfileKey: 2, unresolvedFollowKey: 3], "A profile resolved ten minutes ago is looked up again") } + /// Each resolved profile used to copy, sort and publish the whole list, 54 times per refresh of 61 contacts, even when + /// the row already showed that profile. + func testRefreshPublishesNothingForProfilesTheRowsAlreadyShow() async throws { + let clock = TestClock() + let manager = ContactsManager(currentDate: { clock.now() }) + let keys = ["e", "j", "k", "m", "c"].map { "pubky" + String(repeating: $0, count: 52) } + let lookups = HeldProfileLookups(profiles: Dictionary(uniqueKeysWithValues: keys.enumerated().map { ($1, "Name \($0)") })) + let records = keys.map { unprofiledRecord(key: $0, label: nil) } + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + await manager.waitForProfileRefreshForTesting() + XCTAssertEqual(manager.contacts.map(\.displayName), (0 ..< 5).map { "Name \($0)" }) + + clock.advance(by: ContactsManager.contactProfileFreshness) + var publishes = 0 + let subscription = manager.$contacts.dropFirst().sink { _ in publishes += 1 } + defer { subscription.cancel() } + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + await manager.waitForProfileRefreshForTesting() + + let fetchedKeys = await lookups.fetchedKeys + XCTAssertEqual(fetchedKeys.count, 10, "Every profile was looked up again once ten minutes passed") + XCTAssertEqual(publishes, 1, "Only the load publishes; lookups that find what the rows already show publish nothing") + } + + func testRefreshAppliesManyResultsInFewPublishesAndEndsWithTheWholeListSorted() async throws { + let manager = ContactsManager() + let alphabet = Array("ybndrfg8ejkmcpqxot1uwisza345h769") + let keys = (0 ..< 20).map { "pubky" + String(repeating: "q", count: 51) + String(alphabet[$0]) } + let names = Dictionary(uniqueKeysWithValues: keys.enumerated().map { ($1, String(format: "Name %02d", 19 - $0)) }) + let lookups = HeldProfileLookups(profiles: names) + await lookups.hold() + let records = keys.enumerated().map { unprofiledRecord(key: $1, label: String(format: "Label %02d", $0)) } + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + while await lookups.heldCount < keys.count { + await Task.yield() + } + + var publishes = 0 + let subscription = manager.$contacts.dropFirst().sink { _ in publishes += 1 } + defer { subscription.cancel() } + await lookups.release() + await manager.waitForProfileRefreshForTesting() + + XCTAssertLessThanOrEqual(publishes, 3, "20 resolved profiles are applied in a few batches, not one publish each") + XCTAssertGreaterThanOrEqual(publishes, 1) + XCTAssertEqual(manager.contacts.map(\.displayName), (0 ..< 20).map { String(format: "Name %02d", $0) }) + } + + func testBatchOvertakenByAResetIsDropped() async throws { + let manager = ContactsManager() + let heldKey = "pubky" + String(repeating: "r", count: 52) + let lookups = PartlyHeldProfileLookups( + profiles: [contactProfileKey: "Alice", unresolvedFollowKey: "Bob", heldKey: "Carol"], + holding: [heldKey] + ) + let records = [ + unprofiledRecord(key: contactProfileKey, label: "Label A"), + unprofiledRecord(key: unresolvedFollowKey, label: "Label B"), + unprofiledRecord(key: heldKey, label: "Label C"), + ] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + try await waitUntil { manager.batchedProfileCountForTesting == 2 } + XCTAssertEqual(Set(manager.contacts.map(\.displayName)), ["Label A", "Label B", "Label C"], "The two profiles wait for their batch") + + var published: [Set] = [] + let subscription = manager.$contacts.dropFirst().sink { published.append(Set($0.map(\.displayName))) } + defer { subscription.cancel() } + manager.reset() + let failing = HeldProfileLookups(profiles: [:]) + try await manager.loadContacts(for: "next-owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await failing.fetch($0) }) + try await Task.sleep(for: ContactsManager.contactRefreshBatchWindow * 2) + + XCTAssertFalse(published.contains { $0.contains("Alice") || $0.contains("Bob") }, "A batch from before the reset must never publish") + XCTAssertEqual(Set(manager.contacts.map(\.displayName)), ["Label A", "Label B", "Label C"]) + await lookups.release() + await manager.waitForProfileRefreshForTesting() + XCTAssertEqual(Set(manager.contacts.map(\.displayName)), ["Label A", "Label B", "Label C"]) + } + + func testContactScreenGetsAProfileHeldForTheNextBatchAtOnce() async throws { + let manager = ContactsManager() + let heldKey = "pubky" + String(repeating: "r", count: 52) + let lookups = PartlyHeldProfileLookups(publishedProfiles: [publishedContactProfile], holding: [heldKey]) + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only"), unprofiledRecord(key: heldKey, label: "Held")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await lookups.fetch($0) }) + try await waitUntil { manager.batchedProfileCountForTesting == 1 } + XCTAssertEqual(manager.contacts.first { $0.publicKey == contactProfileKey }?.displayName, "Label only") + + let interactive = ContactProfileFetchStub([.failure(profileTransportError)]) + await manager.resolvePendingContactProfile(publicKey: contactProfileKey) { try await interactive.fetch($0) } + + let row = manager.contacts.first { $0.publicKey == contactProfileKey }?.profile + XCTAssertEqual(row?.name, "Alice", "The screen gets the profile its batch holds without waiting for the batch") + XCTAssertEqual(row?.bio, "Hello", "An edit made now keeps the bio the refresh found") + let attempts = await interactive.attempts + XCTAssertEqual(attempts, 0, "A profile the refresh already found is not looked up again") + await lookups.release() + await manager.waitForProfileRefreshForTesting() + } + + private func waitUntil(timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async throws { + let deadline = ContinuousClock.now + timeout + while !condition() { + guard ContinuousClock.now < deadline else { + XCTFail("Timed out waiting for the condition") + return + } + await Task.yield() + } + } + func testResetForgetsResolvedProfilesSoTheNextLoadLooksThemUpAgain() async throws { let clock = TestClock() let manager = ContactsManager(currentDate: { clock.now() }) @@ -1590,6 +1701,39 @@ private actor HeldProfileLookups { } } +/// Answers profile lookups at once with the scripted profiles, failing for any other key, except for `holding`, whose +/// lookups wait until released. +private actor PartlyHeldProfileLookups { + private let profiles: [String: Bitkit.PubkyProfile] + private let heldKeys: Set + private var held: [CheckedContinuation] = [] + + init(profiles names: [String: String], holding heldKeys: Set) { + profiles = Dictionary(uniqueKeysWithValues: names.map { publicKey, name in + (publicKey, Bitkit.PubkyProfile(publicKey: publicKey, name: name, bio: "", imageUrl: nil, links: [], status: nil)) + }) + self.heldKeys = heldKeys + } + + init(publishedProfiles: [Bitkit.PubkyProfile], holding heldKeys: Set) { + profiles = Dictionary(uniqueKeysWithValues: publishedProfiles.map { ($0.publicKey, $0) }) + self.heldKeys = heldKeys + } + + func release() { + held.forEach { $0.resume() } + held.removeAll() + } + + func fetch(_ publicKey: String) async throws -> Bitkit.PubkyProfile? { + if heldKeys.contains(publicKey) { + await withCheckedContinuation { held.append($0) } + } + guard let profile = profiles[publicKey] else { throw profileTransportError } + return profile + } +} + /// A clock the test moves by hand, for the time a contact profile was resolved. private final class TestClock: @unchecked Sendable { private let lock = NSLock() diff --git a/journeys/pubky-profile/README.md b/journeys/pubky-profile/README.md index 493ef48d6..f19e71812 100644 --- a/journeys/pubky-profile/README.md +++ b/journeys/pubky-profile/README.md @@ -19,8 +19,9 @@ contacts itself is covered by [`journeys/contacts`](../contacts/README.md). Wher finishes after you left does not take you to Pay Contacts. Contacts then lists every follow. - `contacts-list-loading.xml` relaunches and opens Contacts. The saved contacts show straight away under the names they were saved with, and each fills in its profile name and avatar as its lookup - finishes. Reopening Contacts in the same session shows the profiles already found straight away, - and does not look up again a profile found less than ten minutes ago, so only contacts still + finishes. The rows fill in a few at a time, at most every 300 ms, rather than the list re-sorting + once per contact. Reopening Contacts in the same session shows the profiles already found straight + away, and does not look up again a profile found less than ten minutes ago, so only contacts still without a profile get a new lookup. Opening a contact whose profile has not loaded yet looks it up at once, and its edit form shows the published bio. diff --git a/journeys/pubky-profile/contacts-list-loading.xml b/journeys/pubky-profile/contacts-list-loading.xml index 831f2f080..6d375ab7f 100644 --- a/journeys/pubky-profile/contacts-list-loading.xml +++ b/journeys/pubky-profile/contacts-list-loading.xml @@ -1,9 +1,10 @@ Contacts shows the saved contacts as soon as it opens after a relaunch, under the names they were - saved with, and fills in each contact's profile name and avatar as its lookup finishes. It no - longer shows a spinner over an empty list until every lookup has finished, and a lookup that - fails leaves the row as it is. Within the session, reopening Contacts shows the profiles already + saved with, and fills in each contact's profile name and avatar as its lookup finishes, a few rows + at a time at most every 300 ms rather than re-sorting the list once per contact. It no longer + shows a spinner over an empty list until every lookup has finished, and a lookup that fails + leaves the row as it is. Within the session, reopening Contacts shows the profiles already found straight away, and a profile found less than ten minutes ago is not looked up again, so only contacts still without a profile get a new lookup. Opening a contact whose profile has not loaded yet looks it up at once, and its edit form shows the contact's published bio rather than an empty From e2c68bf26f359bbdfadab0d5a62287015cbf742e Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 20:11:27 +0200 Subject: [PATCH 72/79] fix: remember failed pubky avatar fetches An avatar that could not load was fetched again each time its row appeared. A missing or oversize avatar is not fetched again until the image cache is cleared, and an avatar whose fetch failed for another reason is retried after 60 seconds. Sign-out and an identity change, which clear the image cache, forget every failure, and a fetch that a clear overtook or that was cancelled leaves nothing behind. --- Bitkit/Components/PubkyImage.swift | 110 ++++++++++++++++++--- BitkitTests/PubkyImageCacheTests.swift | 129 +++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 12 deletions(-) diff --git a/Bitkit/Components/PubkyImage.swift b/Bitkit/Components/PubkyImage.swift index 696b09185..ea9a4d445 100644 --- a/Bitkit/Components/PubkyImage.swift +++ b/Bitkit/Components/PubkyImage.swift @@ -1,5 +1,6 @@ import CryptoKit import ImageIO +import Paykit import SwiftUI /// Loads and displays an image from a `pubky://` URI using Paykit's Pubky file fetch. @@ -60,29 +61,52 @@ struct PubkyImage: View { try await Self.loadImageOffMain(uri: uri) }.value uiImage = image + } catch PubkyImageError.recentlyFailed { + hasFailed = true } catch { Logger.error("Failed to load pubky image: \(error)", context: "PubkyImage") hasFailed = true } } - /// All heavy work (disk cache, network/FFI) runs off the main actor. - private nonisolated static func loadImageOffMain(uri: String) async throws -> UIImage { - let cacheGeneration = PubkyImageCache.shared.generation - if let cached = await PubkyImageCache.shared.image(for: uri, generation: cacheGeneration) { + /// All heavy work (disk cache, network/FFI) runs off the main actor. A URI whose fetch failed recently fails at once + /// without the network, while a disk cache hit still wins. + nonisolated static func loadImageOffMain( + uri: String, + cache: PubkyImageCache = .shared, + fetchFile: @escaping @Sendable (_ uri: String, _ maxBytes: UInt64) async throws -> Data = { + try await PubkyService.fetchFile(uri: $0, maxBytes: $1) + } + ) async throws -> UIImage { + let cacheGeneration = cache.generation + if let cached = await cache.image(for: uri, generation: cacheGeneration) { return cached } + guard !cache.hasRecentFailure(for: uri) else { + throw PubkyImageError.recentlyFailed + } - let data = try await PubkyService.fetchFile(uri: uri, maxBytes: PubkyImagePolicy.maxDownloadBytes) - let blobData = try await resolveImageData(data, originalUri: uri) + do { + let data = try await fetchFile(uri, PubkyImagePolicy.maxDownloadBytes) + let blobData = try await resolveImageData(data, originalUri: uri, fetchFile: fetchFile) - let image = try PubkyImageDecoder.image(from: blobData) + let image = try PubkyImageDecoder.image(from: blobData) - PubkyImageCache.shared.store(image, data: blobData, for: uri, generation: cacheGeneration) - return image + cache.store(image, data: blobData, for: uri, generation: cacheGeneration) + return image + } catch { + if !Task.isCancelled { + cache.rememberFailure(error, for: uri, generation: cacheGeneration) + } + throw error + } } - private nonisolated static func resolveImageData(_ data: Data, originalUri: String) async throws -> Data { + private nonisolated static func resolveImageData( + _ data: Data, + originalUri: String, + fetchFile: @Sendable (_ uri: String, _ maxBytes: UInt64) async throws -> Data + ) async throws -> Data { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let src = json["src"] as? String, src.hasPrefix("pubky://") @@ -98,7 +122,7 @@ struct PubkyImage: View { } Logger.debug("File descriptor found, fetching blob from: \(src)", context: "PubkyImage") - return try await PubkyService.fetchFile(uri: src, maxBytes: PubkyImagePolicy.maxDownloadBytes) + return try await fetchFile(src, PubkyImagePolicy.maxDownloadBytes) } } @@ -107,6 +131,8 @@ enum PubkyImagePolicy { static let maxPixelSize = 512 static let memoryCacheBytes = 32 * 1024 * 1024 static let diskCacheBytes = 32 * 1024 * 1024 + /// How long an image whose fetch failed for a reason that can pass, such as a network error, is not fetched again. + static let transientFailureTTL: TimeInterval = 60 } enum PubkyImageDecoder { @@ -132,6 +158,7 @@ private enum PubkyImageError: LocalizedError { case decodingFailed(Int) case fileTooLarge(Int) case crossUserRedirect + case recentlyFailed var errorDescription: String? { switch self { @@ -141,12 +168,19 @@ private enum PubkyImageError: LocalizedError { return "Image blob exceeds the byte limit (\(bytes) bytes)" case .crossUserRedirect: return "Image descriptor references a different user's namespace" + case .recentlyFailed: + return "Image fetch failed recently" } } } /// Two-tier cache (memory + disk) so profile images persist across app launches /// and multiple PubkyImage views with the same URI don't re-fetch. +/// +/// It also remembers failed fetches by URI, so an image that cannot load is not fetched again each time it is shown. A +/// missing file and a file over `PubkyImagePolicy.maxDownloadBytes` are remembered until `clear()`, which sign-out and +/// a switch to another identity run, and any other failure for `PubkyImagePolicy.transientFailureTTL`. For a file +/// descriptor, a failed blob fetch counts as a failure of the descriptor URI. final class PubkyImageCache: @unchecked Sendable { static let shared = PubkyImageCache() @@ -156,7 +190,13 @@ final class PubkyImageCache: @unchecked Sendable { var lastAccess: UInt64 } + private struct Failure { + let expiresAt: Date? + } + private var memoryCache: [String: MemoryEntry] = [:] + /// Guarded by `memoryLock`, like the memory cache, so `clear()` empties both at once. + private var failures: [String: Failure] = [:] private var memoryCost = 0 private var accessSequence: UInt64 = 0 private var clearGeneration: UInt64 = 0 @@ -166,18 +206,21 @@ final class PubkyImageCache: @unchecked Sendable { private let maxFileBytes: Int private let maxMemoryBytes: Int private let maxDiskBytes: Int + private let currentDate: @Sendable () -> Date init( diskDirectory: URL? = nil, maxFileBytes: Int = Int(PubkyImagePolicy.maxDownloadBytes), memoryCostLimit: Int = PubkyImagePolicy.memoryCacheBytes, - diskByteLimit: Int = PubkyImagePolicy.diskCacheBytes + diskByteLimit: Int = PubkyImagePolicy.diskCacheBytes, + currentDate: @escaping @Sendable () -> Date = { Date() } ) { let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! self.diskDirectory = diskDirectory ?? caches.appendingPathComponent("pubky-images", isDirectory: true) self.maxFileBytes = maxFileBytes maxMemoryBytes = memoryCostLimit maxDiskBytes = diskByteLimit + self.currentDate = currentDate try? FileManager.default.createDirectory(at: self.diskDirectory, withIntermediateDirectories: true) diskQueue.async { [self] in trimDiskCache() @@ -250,6 +293,48 @@ final class PubkyImageCache: @unchecked Sendable { } } + /// True while a failed fetch of `uri` is remembered, so the image shows its failed state without fetching again. + func hasRecentFailure(for uri: String) -> Bool { + memoryLock.lock() + defer { memoryLock.unlock() } + guard let failure = failures[uri] else { return false } + if let expiresAt = failure.expiresAt, currentDate() >= expiresAt { + failures[uri] = nil + return false + } + return true + } + + /// Remembers that a fetch of `uri` failed with `error`. Like `store`, it is dropped when `clear()` ran after + /// `generation` was captured, and a cancelled fetch is not remembered. + func rememberFailure(_ error: Error, for uri: String, generation: UInt64) { + guard !(error is CancellationError) else { return } + let expiresAt = Self.isPermanentFailure(error) ? nil : currentDate().addingTimeInterval(PubkyImagePolicy.transientFailureTTL) + + memoryLock.lock() + defer { memoryLock.unlock() } + guard generation == clearGeneration else { return } + failures[uri] = Failure(expiresAt: expiresAt) + } + + /// A missing file, or one over the download limit, stays that way until the profile points at another image. + private static func isPermanentFailure(_ error: Error) -> Bool { + if case .profileNotFound? = error as? PubkyServiceError { + return true + } + if case .fileTooLarge? = error as? PubkyImageError { + return true + } + switch error as? PaykitError { + case .NotFound?: + return true + case let .Protocol(_, context)?: + return context.contains("exceeds maximum size") + default: + return false + } + } + func clear() async { clearMemoryCache() @@ -266,6 +351,7 @@ final class PubkyImageCache: @unchecked Sendable { memoryLock.lock() clearGeneration &+= 1 memoryCache.removeAll() + failures.removeAll() memoryCost = 0 memoryLock.unlock() } diff --git a/BitkitTests/PubkyImageCacheTests.swift b/BitkitTests/PubkyImageCacheTests.swift index bc64350b1..f7af4598b 100644 --- a/BitkitTests/PubkyImageCacheTests.swift +++ b/BitkitTests/PubkyImageCacheTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import CryptoKit +import Paykit import UIKit import XCTest @@ -201,6 +202,101 @@ final class PubkyImageCacheTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: path.path)) } + /// Contacts refetched each avatar that cannot load every time it appeared: 4 failing URIs, 5 times each, 2 reads a time. + func testPermanentFailureSkipsTheNetworkUntilTheCacheIsCleared() async throws { + // `PaykitError.Protocol` would name the metatype, so the case is spelled with a contextual type. + let oversize: PaykitError = .Protocol(code: "protocol_error", context: "fetch Pubky file: resource exceeds maximum size of 1048576 bytes") + let permanentErrors: [Error] = [ + PubkyServiceError.profileNotFound, + PaykitError.NotFound(code: "not_found", context: "fetch Pubky file"), + oversize, + ] + for error in permanentErrors { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let clock = TestClock() + let cache = PubkyImageCache(diskDirectory: directory, currentDate: { clock.now() }) + let fetches = FileFetchStub(failingWith: error) + let load = { try await PubkyImage.loadImageOffMain(uri: self.avatarURI, cache: cache, fetchFile: { try await fetches.fetch($0, $1) }) } + + _ = try? await load() + _ = try? await load() + clock.advance(by: 24 * 60 * 60) + _ = try? await load() + var count = await fetches.count + XCTAssertEqual(count, 1, "\(error): a missing or oversize image is not fetched again") + + await cache.clear() + _ = try? await load() + count = await fetches.count + XCTAssertEqual(count, 2, "\(error): clearing the image cache, as sign-out does, forgets the failure") + } + } + + func testTransientFailureIsFetchedAgainOnlyAfterItsWindow() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let clock = TestClock() + let cache = PubkyImageCache(diskDirectory: directory, currentDate: { clock.now() }) + let fetches = FileFetchStub(failingWith: PaykitError.Transport(code: "transport_error", context: "fetch Pubky file")) + let load = { try await PubkyImage.loadImageOffMain(uri: self.avatarURI, cache: cache, fetchFile: { try await fetches.fetch($0, $1) }) } + + _ = try? await load() + clock.advance(by: PubkyImagePolicy.transientFailureTTL - 1) + _ = try? await load() + var count = await fetches.count + XCTAssertEqual(count, 1, "A failed fetch is not repeated within its window") + + clock.advance(by: 1) + _ = try? await load() + count = await fetches.count + XCTAssertEqual(count, 2, "A failure that can pass is fetched again once its window ends") + + await cache.clear() + _ = try? await load() + count = await fetches.count + XCTAssertEqual(count, 3, "Clearing the image cache forgets a transient failure too") + } + + func testFailureOfAFetchThatAClearOrCancellationOvertookIsNotRemembered() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = PubkyImageCache(diskDirectory: directory) + let notFound = PubkyServiceError.profileNotFound + + // A sign-out clears the cache while the fetch runs. + _ = try? await PubkyImage.loadImageOffMain(uri: avatarURI, cache: cache, fetchFile: { _, _ in + await cache.clear() + throw notFound + }) + XCTAssertFalse(cache.hasRecentFailure(for: avatarURI), "A failure from before the clear belongs to the previous identity") + + // The fetch is cancelled, and the read reports cancellation either as such or as an ordinary error. + let errorsOnceCancelled: [Error] = [CancellationError(), notFound] + for errorOnceCancelled in errorsOnceCancelled { + let started = expectation(description: "Fetch started") + let load = Task { + try await PubkyImage.loadImageOffMain(uri: self.avatarURI, cache: cache, fetchFile: { _, _ in + started.fulfill() + try? await Task.sleep(for: .seconds(30)) + throw errorOnceCancelled + }) + } + await fulfillment(of: [started], timeout: 2) + load.cancel() + _ = await load.result + XCTAssertFalse(cache.hasRecentFailure(for: avatarURI), "A cancelled fetch (\(errorOnceCancelled)) is not remembered") + } + + let fetches = FileFetchStub(failingWith: notFound) + _ = try? await PubkyImage.loadImageOffMain(uri: avatarURI, cache: cache, fetchFile: { try await fetches.fetch($0, $1) }) + let count = await fetches.count + XCTAssertEqual(count, 1, "The next appearance fetches the image again") + XCTAssertTrue(cache.hasRecentFailure(for: avatarURI)) + } + + private let avatarURI = "pubky://test-user/pub/pubky.app/files/0033ABCDEFGHJ" + private func pubkyImageDiskPath(for uri: String, directory: URL? = nil) -> URL { let caches = directory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! let hash = SHA256.hash(data: Data(uri.utf8)).compactMap { String(format: "%02x", $0) }.joined() @@ -241,3 +337,36 @@ final class PubkyImageCacheTests: XCTestCase { } } } + +/// Counts file fetches and fails each one with `error`. +private actor FileFetchStub { + private let error: Error + private(set) var count = 0 + + init(failingWith error: Error) { + self.error = error + } + + func fetch(_: String, _: UInt64) throws -> Data { + count += 1 + throw error + } +} + +/// A clock the test moves by hand. +private final class TestClock: @unchecked Sendable { + private let lock = NSLock() + private var current = Date(timeIntervalSince1970: 1_790_000_000) + + func now() -> Date { + lock.lock() + defer { lock.unlock() } + return current + } + + func advance(by interval: TimeInterval) { + lock.lock() + current += interval + lock.unlock() + } +} From bcb3a921fe5973f59b7510bf98da7ae4cef3475b Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 20:20:14 +0200 Subject: [PATCH 73/79] fix: stop looking up the profiles of removed contacts Removing a contact, or deleting them all with Delete Profile, left the running profile refresh looking them up, so it kept issuing reads for contacts that no longer existed. Each contact's lookup is now its own task: removing a contact cancels its lookup and drops anything it found, and deleting all contacts stops the refresh first, so lookups still waiting for a read slot never read. --- Bitkit/Managers/ContactsManager.swift | 75 ++++++++++++++++++-------- BitkitTests/ContactsManagerTests.swift | 65 ++++++++++++++++++++++ 2 files changed, 118 insertions(+), 22 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 603c822d8..70d8fc48b 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -138,6 +138,8 @@ class ContactsManager: ObservableObject { private let fetchFollows: @Sendable (String) async throws -> [String] private let fetchRemoteProfile: @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? private let saveContactLabel: @Sendable (_ publicKey: String, _ label: String) async throws -> Void + private let removeContactRecord: @Sendable (_ publicKey: String) async throws -> Void + private let forgetRemovedContacts: @Sendable (_ publicKeys: [String]) async -> Void private let currentDate: @Sendable () -> Date init( @@ -149,12 +151,20 @@ class ContactsManager: ObservableObject { saveContactLabel: @escaping @Sendable (_ publicKey: String, _ label: String) async throws -> Void = { _ = try await PubkyService.saveContact(publicKey: $0, label: $1) }, + removeContactRecord: @escaping @Sendable (_ publicKey: String) async throws -> Void = { + _ = try await PubkyService.removeContact(publicKey: $0) + }, + forgetRemovedContacts: @escaping @Sendable (_ publicKeys: [String]) async -> Void = { + await PrivatePaykitService.shared.removeSavedContacts(publicKeys: $0) + }, currentDate: @escaping @Sendable () -> Date = { Date() } ) { self.contactRecords = contactRecords self.fetchFollows = fetchFollows self.fetchRemoteProfile = fetchRemoteProfile self.saveContactLabel = saveContactLabel + self.removeContactRecord = removeContactRecord + self.forgetRemovedContacts = forgetRemovedContacts self.currentDate = currentDate } @@ -368,27 +378,25 @@ class ContactsManager: ObservableObject { profileRefreshCount += 1 let refreshID = profileRefreshCount + let lookups = Dictionary(uniqueKeysWithValues: labels.map { publicKey, label in + let lookup = Task { [weak self] in + let profile = try? await Self.resolveContactProfile(publicKey: publicKey, fetchRemoteProfile: fetchRemoteProfile) + self?.finishRefreshLookup(of: publicKey, profile: profile?.withNameFallback(label), refreshID: refreshID) + } + return (publicKey, lookup) + }) let task = Task { [weak self] in - await withTaskGroup(of: (publicKey: String, profile: PubkyProfile?).self) { group in - for (publicKey, label) in labels { - group.addTask { - let profile = try? await Self.resolveContactProfile(publicKey: publicKey, fetchRemoteProfile: fetchRemoteProfile) - return (publicKey, profile?.withNameFallback(label)) - } - } - - for await result in group { - self?.finishRefreshLookup(of: result.publicKey, profile: result.profile, refreshID: refreshID) - } + for lookup in lookups.values { + await lookup.value } self?.applyBatchedProfiles(of: refreshID) self?.finishProfileRefresh(refreshID) } - profileRefresh = ContactProfileRefresh(id: refreshID, labels: labels, pendingKeys: Set(labels.keys), task: task) + profileRefresh = ContactProfileRefresh(id: refreshID, labels: labels, pendingKeys: Set(labels.keys), lookups: lookups, task: task) } - /// Drops the result for a contact whose lookup a screen took over. A task group cannot cancel one of its lookups, so - /// that contact's read still runs. Any other profile found joins the next batch. + /// Drops the result for a contact whose lookup a screen took over; the refresh leaves its own read of that contact + /// running. Any other profile found joins the next batch. private func finishRefreshLookup(of publicKey: String, profile: PubkyProfile?, refreshID: Int) { guard profileRefresh?.id == refreshID, profileRefresh?.pendingKeys.remove(publicKey) != nil, let profile else { return } rememberResolvedProfile(profile, for: publicKey) @@ -416,6 +424,17 @@ class ContactsManager: ObservableObject { profileRefresh = nil } + /// Cancels the running refresh's lookups of removed contacts, so a lookup still waiting for a read slot never reads, + /// and drops whatever they found. + private func stopProfileLookups(for publicKeys: [String]) { + for publicKey in publicKeys { + let key = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + profileRefresh?.lookups[key]?.cancel() + profileRefresh?.pendingKeys.remove(key) + profileRefresh?.batchedProfiles[key] = nil + } + } + private func finishProfileRefresh(_ refreshID: Int) { if profileRefresh?.id == refreshID { profileRefresh = nil @@ -751,24 +770,32 @@ class ContactsManager: ObservableObject { // MARK: - Delete Contact + /// Also stops the running profile refresh's lookup of the contact. func removeContact(publicKey: String) async throws { let prefixedKey = ensurePubkyPrefix(publicKey) + let removeContactRecord = removeContactRecord try await Task.detached { - _ = try await PubkyService.removeContact(publicKey: prefixedKey) + try await removeContactRecord(prefixedKey) }.value contacts.removeAll { $0.publicKey == prefixedKey } + stopProfileLookups(for: [prefixedKey]) Self.removeContactProfileOverride(publicKey: prefixedKey) - await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey) + await forgetRemovedContacts([prefixedKey]) Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") } + /// Stops the running profile refresh first: every contact is going, and its reads would only compete with the ones + /// removing them. func deleteAllContacts() async throws { + stopProfileRefresh() + let contactRecords = contactRecords + let removeContactRecord = removeContactRecord let records: [ContactRecord] do { records = try await Task.detached { - try await PubkyService.contactRecords() + try await contactRecords() }.value } catch { guard Self.isMissingContactsDataError(error) else { @@ -789,7 +816,7 @@ class ContactsManager: ObservableObject { guard let contactKey = PubkyPublicKeyFormat.normalized(record.publicKey) else { continue } do { try await Task.detached { - _ = try await PubkyService.removeContact(publicKey: contactKey) + try await removeContactRecord(contactKey) }.value deletedKeys.insert(contactKey) } catch { @@ -800,7 +827,7 @@ class ContactsManager: ObservableObject { if let firstError { if !deletedKeys.isEmpty { - await PrivatePaykitService.shared.removeSavedContacts(publicKeys: Array(deletedKeys)) + await forgetRemovedContacts(Array(deletedKeys)) for publicKey in deletedKeys { Self.removeContactProfileOverride(publicKey: publicKey) } @@ -810,7 +837,7 @@ class ContactsManager: ObservableObject { } // All remote deletes succeeded, so clear any local-only contacts too. - await PrivatePaykitService.shared.removeSavedContacts(publicKeys: Array(deletedKeys)) + await forgetRemovedContacts(Array(deletedKeys)) Self.clearContactProfileOverrides() await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts.removeAll() @@ -925,18 +952,22 @@ class ContactsManager: ObservableObject { } /// A background refresh of saved contacts' profiles. `labels` maps each contact it looks up to its saved label, and - /// `pendingKeys` holds those whose lookup has not finished and that no screen's lookup has taken over, whose rows - /// may still show only that label. + /// `pendingKeys` holds those whose lookup has not finished, that no screen's lookup has taken over and that were not + /// removed, whose rows may still show only that label. private struct ContactProfileRefresh { let id: Int let labels: [String: String?] var pendingKeys: Set + /// One lookup per contact, so removing a contact can cancel its lookup alone. + let lookups: [String: Task] + /// Finishes once every lookup has. let task: Task /// Profiles found and not applied to the rows yet, applied together once `batchFlush` fires or the refresh ends. var batchedProfiles: [String: PubkyProfile] = [:] var batchFlush: Task? func cancel() { + lookups.values.forEach { $0.cancel() } task.cancel() batchFlush?.cancel() } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 44e619897..8f94cd215 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1526,6 +1526,71 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(manager.completePendingImport(), "An import that finishes after the user left must not pull them away") } + /// Removing contacts left the running profile refresh looking them up, so it kept issuing reads for deleted contacts. + func testRemovingAContactStopsItsQueuedProfileLookup() async throws { + snapshotAppDefaults("pubkyContactProfileOverrides") + let slot = PaykitSdkReadLimiter(maxConcurrent: 1) + let blockingRead = try await holdTheOnlyReadSlot(slot) + let removedKey = "pubky" + String(repeating: "r", count: 52) + let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice", removedKey: "Removed"]) + let manager = ContactsManager(removeContactRecord: { _ in }, forgetRemovedContacts: { _ in }) + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only"), unprofiledRecord(key: removedKey, label: "To remove")] + try await manager.loadContacts( + for: "owner", + fetchContactRecords: { records }, + fetchRemoteProfile: { key in try await slot.withSlot(priority: .bulk) { try await lookups.fetch(key) } } + ) + while slot.waiterCountForTesting < 2 { + await Task.yield() + } + + try await manager.removeContact(publicKey: removedKey) + XCTAssertEqual(slot.waiterCountForTesting, 1, "The removed contact's lookup leaves the read queue at once") + + await blockingRead.release() + await manager.waitForProfileRefreshForTesting() + let fetchedKeys = await lookups.fetchedKeys + XCTAssertEqual(fetchedKeys, [contactProfileKey], "No read is issued for the removed contact") + XCTAssertEqual(manager.contacts.map(\.displayName), ["Alice"]) + } + + func testDeletingAllContactsStopsTheRunningProfileRefresh() async throws { + snapshotAppDefaults("pubkyContactProfileOverrides") + let slot = PaykitSdkReadLimiter(maxConcurrent: 1) + let blockingRead = try await holdTheOnlyReadSlot(slot) + let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice", unresolvedFollowKey: "Bob"]) + let records = [unprofiledRecord(key: contactProfileKey, label: "First"), unprofiledRecord(key: unresolvedFollowKey, label: "Second")] + let manager = ContactsManager(contactRecords: { records }, removeContactRecord: { _ in }, forgetRemovedContacts: { _ in }) + try await manager.loadContacts( + for: "owner", + fetchContactRecords: { records }, + fetchRemoteProfile: { key in try await slot.withSlot(priority: .bulk) { try await lookups.fetch(key) } } + ) + while slot.waiterCountForTesting < 2 { + await Task.yield() + } + + try await manager.deleteAllContacts() + XCTAssertEqual(slot.waiterCountForTesting, 0, "Every queued lookup leaves the read queue once all contacts are deleted") + + await blockingRead.release() + await manager.waitForProfileRefreshForTesting() + let fetchedKeys = await lookups.fetchedKeys + XCTAssertEqual(fetchedKeys, [], "No read is issued for a deleted contact") + XCTAssertTrue(manager.contacts.isEmpty) + } + + /// Takes `slot`'s only read slot with another read and holds it until the returned lookups are released. + private func holdTheOnlyReadSlot(_ slot: PaykitSdkReadLimiter) async throws -> HeldProfileLookups { + let otherRead = HeldProfileLookups(profiles: [:]) + await otherRead.hold() + Task { _ = try? await slot.withSlot(priority: .bulk) { try await otherRead.fetch("other") } } + while await otherRead.heldCount < 1 { + await Task.yield() + } + return otherRead + } + func testDeleteAllContactsThrowsWithoutActiveSession() async { let manager = ContactsManager() manager.contacts = [ From 7186200baa2e3e2a19a696f79dde254df8e77eeb Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 21:36:06 +0200 Subject: [PATCH 74/79] fix: bind a contact edit to its session before it waits Save on a contact's edit screen first waits for the lookup of a profile the row is still waiting for, and then may upload a new avatar. It only bound itself to the Contacts reset generation once those waits were over, and never to the Pubky session. If the user left, signed out and adopted another identity that had saved the same contact before the lookup finished, the old edit filled its form from the new identity's row, saved its label through the new session, wrote back the local override sign-out had cleared, and showed a toast and navigated. ContactsManager.saveContactEdit now binds the edit to the Pubky session and the reset generation when Save is tapped, using the same check as queued tag changes. A stale edit stops after the lookup, after building the edit (including the avatar upload) and around the save, without saving, writing an override, changing a row or reporting an error, and the screen shows no toast and does not navigate. --- Bitkit/Managers/ContactsManager.swift | 70 ++++-- Bitkit/Views/Contacts/EditContactView.swift | 45 ++-- BitkitTests/ContactsManagerTests.swift | 239 ++++++++++++++++++++ 3 files changed, 323 insertions(+), 31 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 70d8fc48b..ae11ff86e 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -111,6 +111,15 @@ struct ContactSection: Identifiable { let contacts: [PubkyContact] } +/// The fields an edit on a contact's edit screen saves. +struct ContactEdit { + let name: String + let bio: String + let imageUrl: String? + let links: [PubkyProfileLink] + let tags: [String] +} + // MARK: - ContactsManager @MainActor @@ -673,17 +682,54 @@ class ContactsManager: ObservableObject { // MARK: - Update Contact - /// A reset or owner change while the save runs stops it quietly: it writes no local override and reports no error. - func updateContact(publicKey: String, name: String, bio: String, imageUrl: String?, links: [PubkyProfileLink], tags: [String]) async throws { + /// True while work started now still belongs to its session: `isSessionCurrent` still holds and no reset or other + /// owner's load has run since. + private func sessionCheck(_ isSessionCurrent: @escaping @MainActor () -> Bool) -> @MainActor () -> Bool { let generation = resolvedProfilesGeneration - try await updateContact(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags) { - self.resolvedProfilesGeneration == generation + return { [weak self] in + self?.resolvedProfilesGeneration == generation && isSessionCurrent() + } + } + + /// Saves an edit made on a contact's edit screen once the lookup of a profile the row is still waiting for is done, so + /// the save keeps the bio, links and avatar that lookup finds. `makeEdit` runs only then and returns the fields to + /// save, such as by filling the form again from the row and uploading a new avatar. Returns the saved profile. + /// + /// An edit belongs to the session Save was tapped in, bound before it waits for anything. Once `isSessionCurrent` is + /// false, or a reset or another owner's load has run, it stops quietly and returns nil: `makeEdit` does not run, or its + /// result or error is dropped, and it saves nothing, writes no local override and reports no error. The lookup or + /// upload it waits for can finish after a sign-out, and the next identity may have saved a contact with the same key. + func saveContactEdit( + publicKey: String, + isSessionCurrent: @escaping @MainActor () -> Bool, + makeEdit: @MainActor () async throws -> ContactEdit + ) async throws -> PubkyProfile? { + let isCurrent = sessionCheck(isSessionCurrent) + await resolvePendingContactProfile(publicKey: publicKey) + guard isCurrent() else { return nil } + let edit: ContactEdit + do { + edit = try await makeEdit() + } catch { + guard isCurrent() else { return nil } + throw error } + return try await updateContact( + publicKey: publicKey, + name: edit.name, + bio: edit.bio, + imageUrl: edit.imageUrl, + links: edit.links, + tags: edit.tags, + isCurrent: isCurrent + ) } /// `isCurrent` is checked right before the save and again once it returns, with nothing suspending between that check /// and the writes after it. Sign-out clears the local overrides, so a save that lands after the session changed must - /// not write one back, and its error, if any, belongs to a session the user has left. + /// not write one back, and its error, if any, belongs to a session the user has left. Returns the saved profile, or + /// nil when the save was dropped. + @discardableResult private func updateContact( publicKey: String, name: String, @@ -692,7 +738,7 @@ class ContactsManager: ObservableObject { links: [PubkyProfileLink], tags: [String], isCurrent: @MainActor () -> Bool - ) async throws { + ) async throws -> PubkyProfile? { let prefixedKey = ensurePubkyPrefix(publicKey) let contactData = PubkyProfileData( @@ -703,19 +749,19 @@ class ContactsManager: ObservableObject { tags: tags ) - guard isCurrent() else { return } + guard isCurrent() else { return nil } let saveContactLabel = saveContactLabel do { try await Task.detached { try await saveContactLabel(prefixedKey, name) }.value } catch { - guard isCurrent() else { return } + guard isCurrent() else { return nil } throw error } guard isCurrent() else { Logger.info("Dropped a contact update that a session change overtook", context: "ContactsManager") - return + return nil } Self.upsertContactProfileOverride(publicKey: prefixedKey, data: contactData) @@ -726,6 +772,7 @@ class ContactsManager: ObservableObject { } Logger.info("Updated contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") + return updatedProfile } /// Saves a tag change made on a contact's screen over the contact's latest profile once the changes queued before it @@ -743,10 +790,7 @@ class ContactsManager: ObservableObject { transform: @escaping ([String]) -> [String] ) -> Task { let key = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey - let generation = resolvedProfilesGeneration - let isCurrent: @MainActor () -> Bool = { [weak self] in - self?.resolvedProfilesGeneration == generation && isSessionCurrent() - } + let isCurrent = sessionCheck(isSessionCurrent) let previousChange = tagChanges[key] let change = Task { _ = await previousChange?.result diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 9aad8279a..c06c57460 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -140,31 +140,30 @@ struct EditContactView: View { // MARK: - Save + /// The save belongs to the Pubky session Save was tapped in. Once that session ends, even while the save still waits + /// for the contact's profile or uploads the avatar, it stops quietly, with no toast and no navigation. private func saveContact() async { - guard !form.trimmedName.isEmpty else { return } + let pubkyProfile = pubkyProfile + guard !form.trimmedName.isEmpty, let session = pubkyProfile.currentSession else { return } isSaving = true defer { isSaving = false } - await contactsManager.resolvePendingContactProfile(publicKey: publicKey) - fillFormFromContact() - do { - let uploadedImageUrl = if let avatarImage { - try await pubkyProfile.uploadAvatar(image: avatarImage) - } else { - form.imageUrl - } - - try await contactsManager.updateContact( + let savedProfile = try await contactsManager.saveContactEdit( publicKey: publicKey, - name: form.trimmedName, - bio: form.bio.trimmingCharacters(in: .whitespacesAndNewlines), - imageUrl: uploadedImageUrl, - links: form.links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, - tags: form.tags - ) - form.imageUrl = uploadedImageUrl + isSessionCurrent: { pubkyProfile.currentSession == session } + ) { + fillFormFromContact() + let uploadedImageUrl = if let avatarImage { + try await pubkyProfile.uploadAvatar(image: avatarImage) + } else { + form.imageUrl + } + return form.edit(imageUrl: uploadedImageUrl) + } + guard let savedProfile else { return } + form.imageUrl = savedProfile.imageUrl app.toast( type: .success, title: t("contacts__edit_saved"), @@ -192,6 +191,16 @@ struct ContactEditForm { name.trimmingCharacters(in: .whitespacesAndNewlines) } + func edit(imageUrl: String?) -> ContactEdit { + ContactEdit( + name: trimmedName, + bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), + imageUrl: imageUrl, + links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, + tags: tags + ) + } + mutating func fill(from profile: PubkyProfile) { let filled = filledProfile if filled.map({ name == $0.name }) ?? true { diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 8f94cd215..b4064b49c 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1387,6 +1387,213 @@ final class ContactsManagerTests: XCTestCase { } } + func testContactEditWaitsForTheContactsLookupAndSavesOverTheProfileItFinds() async throws { + let savedOverrides = ContactsManager.backupContactProfileOverrides() + addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } + ContactsManager.restoreContactProfileOverrides(nil) + let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) + await interactive.hold() + let manager = ContactsManager( + fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, + saveContactLabel: { try await store.save($0, label: $1) } + ) + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + + let screen = ContactEditScreen(manager: manager, publicKey: contactProfileKey) + screen.edit { $0.tags = ["friend"] } + let save = Task { + try await manager.saveContactEdit(publicKey: contactProfileKey, isSessionCurrent: { true }) { + try await screen.makeEdit() + } + } + while await interactive.heldCount < 1 { + await Task.yield() + } + XCTAssertEqual(screen.editsMade, 0, "Save builds the edit only once the contact's lookup is done") + + await interactive.release() + let savedProfile = try await save.value + + XCTAssertEqual(screen.editsMade, 1) + XCTAssertEqual(savedProfile?.name, "Alice", "A field the user left alone takes the profile the lookup found") + XCTAssertEqual(savedProfile?.bio, "Hello") + XCTAssertEqual(savedProfile?.imageUrl, "pubky://alice/avatar") + XCTAssertEqual(savedProfile?.links.map(\.url), ["https://alice.example"]) + XCTAssertEqual(savedProfile?.tags, ["friend"], "The user's change is kept") + let saved = await store.savedLabels + XCTAssertEqual(saved, ["Alice"]) + XCTAssertEqual(ContactsManager.backupContactProfileOverrides()?[contactProfileKey], savedProfile.map { PubkyProfileData.from(profile: $0) }) + XCTAssertEqual(manager.contacts.first?.profile.tags, ["friend"], "The row shows what was saved") + + await bulk.release() + await manager.waitForProfileRefreshForTesting() + } + + /// The review regression for an edit: Save waits for the contact's held lookup, the user leaves, signs out and adopts + /// another identity, and the lookup finishes only then. The next identity may have saved a contact with the same key, + /// which the SDK would accept a save for, or not, which it rejects. + func testContactEditSavedBeforeASignOutSavesNothingForTheNextIdentity() async throws { + let savedOverrides = ContactsManager.backupContactProfileOverrides() + addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } + + for nextIdentityHasContact in [true, false] { + let message = nextIdentityHasContact ? "when the next identity saved the same contact" : "when it has no such contact" + ContactsManager.restoreContactProfileOverrides(nil) + let session = TestPubkySession() + let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) + await interactive.hold() + let manager = ContactsManager( + fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, + saveContactLabel: { try await store.save($0, label: $1) } + ) + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner-a", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + + // The user changes every field but the avatar and taps Save, which waits for the contact's held lookup. + let screen = ContactEditScreen(manager: manager, publicKey: contactProfileKey) + screen.edit { + $0.name = "My Alice" + $0.bio = "Met at the meetup" + $0.links = [ProfileLinkInput(label: "Site", url: "https://old.example")] + $0.tags = ["friend"] + } + let save = Task { + try await manager.saveContactEdit(publicKey: contactProfileKey, isSessionCurrent: session.check()) { + try await screen.makeEdit() + } + } + while await interactive.heldCount < 1 { + await Task.yield() + } + + // The user leaves and signs out, AppScene resets Contacts, and the sign-out clears the overrides. + session.change() + manager.reset() + ContactsManager.restoreContactProfileOverrides(nil) + // Another identity signs in and loads its contacts, whose own lookups are held. + session.change() + await store.signIn(savedKeys: nextIdentityHasContact ? [contactProfileKey] : []) + let nextRecords = nextIdentityHasContact ? [unprofiledRecord(key: contactProfileKey, label: "Bob's label")] : [] + let nextBulk = HeldProfileLookups(profiles: [:]) + await nextBulk.hold() + try await manager.loadContacts(for: "owner-b", fetchContactRecords: { nextRecords }, fetchRemoteProfile: { try await nextBulk.fetch($0) }) + + // The cancelled read finishes anyway. + await interactive.release() + var savedProfile: Bitkit.PubkyProfile? + do { + savedProfile = try await save.value + } catch { + XCTFail("An edit from before the sign-out must not report an error, so no toast shows, \(message): \(error)") + } + + XCTAssertNil(savedProfile, "Nothing is reported saved, so no toast shows and the screen does not navigate, \(message)") + XCTAssertEqual(screen.editsMade, 0, "The form is not filled from the next identity's contact, nor an avatar uploaded, \(message)") + let saved = await store.savedLabels + XCTAssertEqual(saved, [], "Nothing saves through the next identity's session, \(message)") + XCTAssertNil(ContactsManager.backupContactProfileOverrides(), "No override is written back, \(message)") + XCTAssertEqual(manager.contacts.map(\.displayName), nextIdentityHasContact ? ["Bob's label"] : [], message) + XCTAssertEqual(manager.contacts.map(\.profile.bio), nextIdentityHasContact ? [""] : [], message) + XCTAssertEqual(manager.contacts.map(\.profile.links.count), nextIdentityHasContact ? [0] : [], message) + XCTAssertEqual(manager.contacts.map(\.profile.tags), nextIdentityHasContact ? [[]] : [], message) + + await bulk.release() + await nextBulk.release() + await manager.waitForProfileRefreshForTesting() + } + } + + /// Covers each half of a session change on its own: the session ending before AppScene resets Contacts, and a reset with + /// the same identity signing straight back in. Each lands while Save waits for the contact's lookup or uploads the + /// avatar, which then succeeds or fails. + func testContactEditStopsOnceItsSessionEndsOrContactsResetWhileItWaits() async throws { + let savedOverrides = ContactsManager.backupContactProfileOverrides() + addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } + + for wait in ["lookup", "avatar upload", "failed avatar upload"] { + for resetsContacts in [false, true] { + let message = "\(resetsContacts ? "after a reset" : "once the session ends") during the \(wait)" + ContactsManager.restoreContactProfileOverrides(nil) + let session = TestPubkySession() + let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) + if wait == "lookup" { + await interactive.hold() + } + let manager = ContactsManager( + fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, + saveContactLabel: { try await store.save($0, label: $1) } + ) + let bulk = HeldProfileLookups(profiles: [:]) + await bulk.hold() + let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] + try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) + let endSession: @MainActor () async throws -> Void = { + if resetsContacts { + manager.reset() + try await manager.loadContacts( + for: "owner", + fetchContactRecords: { records }, + fetchRemoteProfile: { try await bulk.fetch($0) } + ) + } else { + session.change() + } + } + + let screen = ContactEditScreen(manager: manager, publicKey: contactProfileKey) + screen.edit { + $0.name = "My Alice" + $0.tags = ["friend"] + } + let save = Task { + try await manager.saveContactEdit( + publicKey: contactProfileKey, + isSessionCurrent: resetsContacts ? { true } : session.check() + ) { + guard wait != "lookup" else { return try await screen.makeEdit() } + return try await screen.makeEdit { + try await endSession() + guard wait == "avatar upload" else { throw profileTransportError } + return "pubky://owner/new-avatar" + } + } + } + if wait == "lookup" { + while await interactive.heldCount < 1 { + await Task.yield() + } + try await endSession() + await interactive.release() + } + var savedProfile: Bitkit.PubkyProfile? + do { + savedProfile = try await save.value + } catch { + XCTFail("A stale edit must not report an error \(message): \(error)") + } + + XCTAssertNil(savedProfile, message) + XCTAssertEqual(screen.editsMade, wait == "lookup" ? 0 : 1, message) + let saved = await store.savedLabels + XCTAssertEqual(saved, [], "The stale edit saves nothing \(message)") + XCTAssertNil(ContactsManager.backupContactProfileOverrides(), message) + XCTAssertFalse(manager.contacts.map(\.displayName).contains("My Alice"), message) + XCTAssertEqual(manager.contacts.map(\.profile.tags), [[]], message) + + await bulk.release() + await manager.waitForProfileRefreshForTesting() + } + } + } + func testPreparingAnImportLooksFollowsUpOnTheInteractiveLane() async throws { let lanes = ProfileLookupLanes() let records = [unprofiledRecord(key: contactProfileKey, label: "Label only")] @@ -1860,6 +2067,38 @@ private final class TestPubkySession { } } +/// Stands in for `EditContactView`: the form the user edited, and the edit its Save builds once the contact's lookup is +/// done, by filling the form again from the contact's row and uploading a new avatar. +@MainActor +private final class ContactEditScreen { + private(set) var form = ContactEditForm() + private(set) var editsMade = 0 + private let manager: ContactsManager + private let publicKey: String + + init(manager: ContactsManager, publicKey: String) { + self.manager = manager + self.publicKey = publicKey + fillFromRow() + } + + func edit(_ change: (inout ContactEditForm) -> Void) { + change(&form) + } + + func makeEdit(uploadAvatar: () async throws -> String? = { nil }) async throws -> ContactEdit { + editsMade += 1 + fillFromRow() + let uploadedImageUrl = try await uploadAvatar() ?? form.imageUrl + return form.edit(imageUrl: uploadedImageUrl) + } + + private func fillFromRow() { + guard let row = manager.contacts.first(where: { $0.publicKey == publicKey }) else { return } + form.fill(from: row.profile) + } +} + private struct ContactNotSavedError: Error {} /// Stands in for the SDK's contact storage of whichever identity is signed in. Like the SDK, it saves a label only for a From 0444524b4d757ab19af640bcb59e29ef449d5782 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 21:40:56 +0200 Subject: [PATCH 75/79] fix: ignore a missing-profile refresh that a profile save overtakes Adopting a remembered Ring row reuses its cached profile and refreshes it in the background. A profile save only bumped the profile write generation once its publication returned, so a refresh that found the profile missing while the save was still publishing was accepted: it cleared the profile and persisted profile setup as pending. The save then committed the edited profile but left setup pending, so MainNav could prompt Create Profile after a successful edit, also after a relaunch. A profile save now drops every profile read that started before it as soon as its write starts, and a published profile clears pending setup. A save that a sign-out or another identity overtakes no longer commits its profile or touches setup state, so it cannot clear the next identity's pending setup or show the earlier identity's profile. --- Bitkit/Managers/PubkyProfileManager.swift | 43 +++++- BitkitTests/PubkyProfileManagerTests.swift | 151 +++++++++++++++++++++ 2 files changed, 189 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 98db21f38..f14fd0a72 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -56,6 +56,7 @@ class PubkyProfileManager: ObservableObject { } typealias RemoteProfileResolver = @Sendable (String) async throws -> PubkyProfile + typealias ProfilePublisher = @Sendable (PubkyProfileData) async throws -> Void private enum SessionInitializationMode { case userVisible @@ -92,7 +93,9 @@ class PubkyProfileManager: ObservableObject { private static var sessionRevision = UUID() private static var sessionMutationCount = 0 private let remoteProfileResolver: RemoteProfileResolver - /// Bumped whenever `profile` is written or the identity changes, so a remote read that started earlier is dropped. + private let profilePublisher: ProfilePublisher + /// Bumped whenever a profile save starts, `profile` is written or the identity changes, so a remote read that started + /// earlier is dropped. private var profileWriteGeneration = 0 /// Ring rows whose lookup found nothing. The SDK reports a missing record and an offline failure alike, so a miss /// only stops repeat lookups and must never drive sign-up or profile-setup decisions. @@ -109,8 +112,12 @@ class PubkyProfileManager: ObservableObject { sessionMutationCount -= 1 } - init(remoteProfileResolver: @escaping RemoteProfileResolver = { try await PubkyProfileManager.resolveRemoteProfile(publicKey: $0) }) { + init( + remoteProfileResolver: @escaping RemoteProfileResolver = { try await PubkyProfileManager.resolveRemoteProfile(publicKey: $0) }, + profilePublisher: @escaping ProfilePublisher = { try await PubkyService.publishPaykitProfile($0.toPaykitProfile()) } + ) { self.remoteProfileResolver = remoteProfileResolver + self.profilePublisher = profilePublisher cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) cachedProfileOwner = UserDefaults.standard.string(forKey: Self.cachedProfileOwnerKey) @@ -775,7 +782,23 @@ class PubkyProfileManager: ObservableObject { newImageUrl: String? = nil ) async throws { _ = try activeSessionSecret() + try await publishProfileEdit(name: name, bio: bio, links: links, tags: tags, newImageUrl: newImageUrl) + } + /// Starting the write drops every profile read that started before it, so a refresh that finds the profile missing + /// while this edit publishes it can no longer clear the profile or start profile setup. A published profile ends any + /// pending setup. A save that a sign-out or another identity overtakes changes nothing here, as it belongs to a session + /// the user has left. + private func publishProfileEdit( + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String], + newImageUrl: String? + ) async throws { + let revision = Self.sessionRevision + let editedPublicKey = publicKey + invalidateProfileLoads() let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: profile?.imageUrl) try await writeProfile( @@ -785,10 +808,13 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags ) + guard revision == Self.sessionRevision, publicKey == editedPublicKey else { + Logger.info("Dropped a profile save that a session change overtook", context: "PubkyProfileManager") + return + } - let pk = publicKey ?? "" let updatedProfile = PubkyProfile( - publicKey: pk, + publicKey: editedPublicKey ?? "", name: name, bio: bio, imageUrl: resolvedImageUrl, @@ -797,6 +823,7 @@ class PubkyProfileManager: ObservableObject { status: profile?.status ) commitProfile(updatedProfile) + setProfileSetupPending(false) } func deleteProfile() async throws { @@ -833,8 +860,9 @@ class PubkyProfileManager: ObservableObject { tags: tags ) + let publish = profilePublisher try await Task.detached { - try await PubkyService.publishPaykitProfile(profileData.toPaykitProfile()) + try await publish(profileData) }.value } @@ -900,6 +928,11 @@ class PubkyProfileManager: ObservableObject { try await completeRingAdoption(publicKey: publicKey) } + /// `saveProfile` without its check for a stored session secret. + func saveProfileForTesting(name: String, bio: String, links: [PubkyProfileLink], tags: [String]) async throws { + try await publishProfileEdit(name: name, bio: bio, links: links, tags: tags, newImageUrl: nil) + } + func clearAuthenticatedStateForTesting() { clearAuthenticatedState() } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 49684c5a5..59018060c 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1222,6 +1222,121 @@ final class PubkyProfileManagerTests: XCTestCase { } } + // MARK: - Profile saves + + /// The review regression: a remembered Ring profile with tags was removed remotely. Adoption reuses its cached row and + /// the follow-up lookup is held. The user removes a tag on Profile while the publication is held, the lookup then finds + /// the profile missing, and only then does the publication succeed. + @MainActor + func testProfileSaveKeepsTheSavedProfileWhenAnOlderRefreshFindsItMissing() async throws { + try await withRestoredProfileDefaults { + let tagged = PubkyProfile( + publicKey: ringKeyA, name: "Alice", bio: "bio", imageUrl: nil, links: [], tags: ["friend", "work"], status: nil + ) + let stub = RemoteProfileStub(profiles: [ringKeyA: tagged]) + let publications = ProfilePublications() + await publications.hold() + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { await publications.publish($0) } + ) + await manager.loadRingIdentityProfiles([bareRingKeyA]) + await stub.setProfile(nil, for: ringKeyA) + await stub.setHoldsRequests(true) + + let adopted = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyA) + XCTAssertEqual(adopted?.tags, ["friend", "work"], "Adoption reuses the cached row") + await stub.waitForRequests(2) + + let save = Task { + try await manager.saveProfileForTesting(name: "Alice", bio: "bio", links: [], tags: ["friend"]) + } + await publications.waitUntilHeld(1) + await stub.release(request: 1) + await waitUntil("the refresh finds the profile missing") { !manager.isLoadingProfile } + + XCTAssertFalse(manager.isProfileSetupPending, "A missing profile found while a save publishes it starts no profile setup") + XCTAssertFalse(UserDefaults.standard.bool(forKey: "pubky_profile_setup_pending")) + XCTAssertEqual(manager.profile?.tags, ["friend", "work"], "Nor does it clear the profile while the save runs") + + await publications.release() + try await save.value + + let defaults = UserDefaults.standard + XCTAssertEqual(manager.profile?.tags, ["friend"], "The edited profile shows") + XCTAssertEqual(manager.profile?.publicKey, ringKeyA) + XCTAssertFalse(manager.isProfileSetupPending, "So Create Profile is not prompted") + XCTAssertFalse(defaults.bool(forKey: "pubky_profile_setup_pending")) + XCTAssertEqual(defaults.string(forKey: "pubky_profile_name"), "Alice") + XCTAssertEqual(defaults.string(forKey: "pubky_profile_owner"), ringKeyA) + let published = await publications.published + XCTAssertEqual(published.map(\.tags), [["friend"]]) + + let relaunched = PubkyProfileManager() + XCTAssertFalse(relaunched.isProfileSetupPending, "Nor is setup pending after a relaunch") + XCTAssertEqual(relaunched.cachedName, "Alice") + } + } + + /// A setup left pending while the profile exists, as an earlier build could leave it, prompts Create Profile on every + /// launch. A saved profile ends it. + @MainActor + func testProfileSaveEndsAPendingProfileSetup() async throws { + try await withRestoredProfileDefaults { + UserDefaults.standard.set(true, forKey: "pubky_profile_setup_pending") + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let publications = ProfilePublications() + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { await publications.publish($0) } + ) + manager.publicKey = ringKeyA + await manager.loadProfile() + XCTAssertTrue(manager.isProfileSetupPending) + + try await manager.saveProfileForTesting(name: "Alice", bio: "bio", links: [], tags: ["friend"]) + + XCTAssertEqual(manager.profile?.tags, ["friend"]) + XCTAssertFalse(manager.isProfileSetupPending) + XCTAssertFalse(PubkyProfileManager().isProfileSetupPending, "Nor is setup pending after a relaunch") + } + } + + /// The save was admitted, but before its publication returns the user signs out and adopts another identity, which has + /// no profile and so starts profile setup. + @MainActor + func testProfileSaveThatAnotherIdentityOvertakesLeavesThatIdentitysSetupAlone() async throws { + try await withRestoredProfileDefaults { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let publications = ProfilePublications() + await publications.hold() + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { await publications.publish($0) } + ) + manager.publicKey = ringKeyA + await manager.loadProfile() + + let save = Task { + try await manager.saveProfileForTesting(name: "Alice", bio: "bio", links: [], tags: ["friend"]) + } + await publications.waitUntilHeld(1) + manager.clearAuthenticatedStateForTesting() + let adopted = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyB) + XCTAssertNil(adopted) + XCTAssertTrue(manager.isProfileSetupPending) + + await publications.release() + try await save.value + + XCTAssertEqual(manager.publicKey, ringKeyB) + XCTAssertTrue(manager.isProfileSetupPending, "The next identity still sets its profile up") + XCTAssertTrue(UserDefaults.standard.bool(forKey: "pubky_profile_setup_pending")) + XCTAssertNil(manager.profile, "The earlier identity's profile is not shown for the next one") + XCTAssertNil(manager.cachedName) + } + } + // MARK: - Cached profile preview @MainActor @@ -2595,6 +2710,42 @@ private actor RemoteProfileStub { } } +/// Stands in for publishing the signed-in profile: records each publication and can hold them until released. A wait for +/// held publications that outlasts the deadline fails the test instead of hanging the suite. +private actor ProfilePublications { + private(set) var published: [PubkyProfileData] = [] + private var isHolding = false + private var held: [CheckedContinuation] = [] + + func hold() { + isHolding = true + } + + func release() { + isHolding = false + held.forEach { $0.resume() } + held.removeAll() + } + + func publish(_ profile: PubkyProfileData) async { + if isHolding { + await withCheckedContinuation { held.append($0) } + } + published.append(profile) + } + + func waitUntilHeld(_ count: Int, file: StaticString = #filePath, line: UInt = #line) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while held.count < count { + guard ContinuousClock.now < deadline else { + XCTFail("Timed out waiting for \(count) held publications; saw \(held.count)", file: file, line: line) + return + } + try? await Task.sleep(for: .milliseconds(5)) + } + } +} + @MainActor private class KeyDerivationProbeProfileManager: PubkyProfileManager { var didDeriveKeys = false From 7b4adf0cd02cfc63ac3f94ca4355af4e1bc22229 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 22:12:19 +0200 Subject: [PATCH 76/79] fix: refuse a contact save whose identity changed under the Paykit lock A contact's tag change or edit checks its session right before it saves, but the save then waits for the Paykit operation lock. A sign-out and another identity's sign-in could take the lock first, so the save wrote the old identity's label to the next identity's contact with the same key. The local override and row were dropped, but the SDK write landed. Edit Contact's avatar upload had the same window. The contact save now carries the identity that was signed in when the change was queued or Save was tapped, and PaykitSdkService checks it against the SDK's signed-in identity in the same locked operation as the write. A mismatch writes nothing and throws identityChanged, which ContactsManager drops quietly, with no error and no toast. Edit Contact's avatar upload passes the same identity to the upload's existing check under the lock. --- Bitkit/Managers/ContactsManager.swift | 24 +- Bitkit/Managers/PubkyProfileManager.swift | 15 +- Bitkit/Services/PubkyService.swift | 31 ++- Bitkit/Views/Contacts/ContactDetailView.swift | 1 + Bitkit/Views/Contacts/EditContactView.swift | 6 +- BitkitTests/ContactsManagerTests.swift | 191 +++++++++++--- BitkitTests/PaykitSdkIdentityCheckTests.swift | 249 ++++++++++++++++++ BitkitTests/PubkyProfileManagerTests.swift | 53 ++++ 8 files changed, 517 insertions(+), 53 deletions(-) create mode 100644 BitkitTests/PaykitSdkIdentityCheckTests.swift diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index ae11ff86e..a3d6739b4 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -146,7 +146,7 @@ class ContactsManager: ObservableObject { private let contactRecords: @Sendable () async throws -> [ContactRecord] private let fetchFollows: @Sendable (String) async throws -> [String] private let fetchRemoteProfile: @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? - private let saveContactLabel: @Sendable (_ publicKey: String, _ label: String) async throws -> Void + private let saveContactLabel: @Sendable (_ publicKey: String, _ label: String, _ expectedIdentity: String) async throws -> Void private let removeContactRecord: @Sendable (_ publicKey: String) async throws -> Void private let forgetRemovedContacts: @Sendable (_ publicKeys: [String]) async -> Void private let currentDate: @Sendable () -> Date @@ -157,8 +157,8 @@ class ContactsManager: ObservableObject { fetchRemoteProfile: @escaping @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? = { try await ContactsManager.remoteContactProfile(publicKey: $0, priority: $1) }, - saveContactLabel: @escaping @Sendable (_ publicKey: String, _ label: String) async throws -> Void = { - _ = try await PubkyService.saveContact(publicKey: $0, label: $1) + saveContactLabel: @escaping @Sendable (_ publicKey: String, _ label: String, _ expectedIdentity: String) async throws -> Void = { + _ = try await PubkyService.saveContact(publicKey: $0, label: $1, expectedIdentity: $2) }, removeContactRecord: @escaping @Sendable (_ publicKey: String) async throws -> Void = { _ = try await PubkyService.removeContact(publicKey: $0) @@ -699,8 +699,11 @@ class ContactsManager: ObservableObject { /// false, or a reset or another owner's load has run, it stops quietly and returns nil: `makeEdit` does not run, or its /// result or error is dropped, and it saves nothing, writes no local override and reports no error. The lookup or /// upload it waits for can finish after a sign-out, and the next identity may have saved a contact with the same key. + /// `expectedIdentity` is the pubky signed in when Save was tapped. The save is refused unless it is still signed in + /// when the save runs, and that refusal is dropped just as quietly. func saveContactEdit( publicKey: String, + expectedIdentity: String, isSessionCurrent: @escaping @MainActor () -> Bool, makeEdit: @MainActor () async throws -> ContactEdit ) async throws -> PubkyProfile? { @@ -721,6 +724,7 @@ class ContactsManager: ObservableObject { imageUrl: edit.imageUrl, links: edit.links, tags: edit.tags, + expectedIdentity: expectedIdentity, isCurrent: isCurrent ) } @@ -729,6 +733,10 @@ class ContactsManager: ObservableObject { /// and the writes after it. Sign-out clears the local overrides, so a save that lands after the session changed must /// not write one back, and its error, if any, belongs to a session the user has left. Returns the saved profile, or /// nil when the save was dropped. + /// + /// The session can still change after that first check, while the save waits for the SDK. So the SDK checks that + /// `expectedIdentity` is still signed in, in the same locked operation as its write, and otherwise writes nothing and + /// throws `identityChanged`, which is dropped like any other save `isCurrent` no longer holds for. @discardableResult private func updateContact( publicKey: String, @@ -737,6 +745,7 @@ class ContactsManager: ObservableObject { imageUrl: String?, links: [PubkyProfileLink], tags: [String], + expectedIdentity: String, isCurrent: @MainActor () -> Bool ) async throws -> PubkyProfile? { let prefixedKey = ensurePubkyPrefix(publicKey) @@ -753,8 +762,11 @@ class ContactsManager: ObservableObject { let saveContactLabel = saveContactLabel do { try await Task.detached { - try await saveContactLabel(prefixedKey, name) + try await saveContactLabel(prefixedKey, name, expectedIdentity) }.value + } catch PubkyServiceError.identityChanged { + Logger.info("Dropped a contact update for an identity that is no longer signed in", context: "ContactsManager") + return nil } catch { guard isCurrent() else { return nil } throw error @@ -783,9 +795,12 @@ class ContactsManager: ObservableObject { /// A change belongs to the session it was queued in. Once `isSessionCurrent` is false, or a reset or another owner's /// load has run, it stops quietly: it saves nothing, writes no local override and reports no error. A lookup it waits /// for can still finish after a sign-out, and the next identity may have saved a contact with the same key. + /// `expectedIdentity` is the pubky signed in when the change was queued. The save is refused unless it is still signed + /// in when the save runs, and that refusal is dropped just as quietly. func updateContactTags( publicKey: String, shownProfile: PubkyProfile, + expectedIdentity: String, isSessionCurrent: @escaping @MainActor () -> Bool, transform: @escaping ([String]) -> [String] ) -> Task { @@ -805,6 +820,7 @@ class ContactsManager: ObservableObject { imageUrl: latest.imageUrl, links: latest.links, tags: transform(latest.tags), + expectedIdentity: expectedIdentity, isCurrent: isCurrent ) } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index f14fd0a72..719a0a57b 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -57,6 +57,7 @@ class PubkyProfileManager: ObservableObject { typealias RemoteProfileResolver = @Sendable (String) async throws -> PubkyProfile typealias ProfilePublisher = @Sendable (PubkyProfileData) async throws -> Void + typealias AvatarUploader = @Sendable (_ jpegData: Data, _ expectedIdentity: String?) async throws -> String private enum SessionInitializationMode { case userVisible @@ -94,6 +95,7 @@ class PubkyProfileManager: ObservableObject { private static var sessionMutationCount = 0 private let remoteProfileResolver: RemoteProfileResolver private let profilePublisher: ProfilePublisher + private let avatarUploader: AvatarUploader /// Bumped whenever a profile save starts, `profile` is written or the identity changes, so a remote read that started /// earlier is dropped. private var profileWriteGeneration = 0 @@ -114,10 +116,14 @@ class PubkyProfileManager: ObservableObject { init( remoteProfileResolver: @escaping RemoteProfileResolver = { try await PubkyProfileManager.resolveRemoteProfile(publicKey: $0) }, - profilePublisher: @escaping ProfilePublisher = { try await PubkyService.publishPaykitProfile($0.toPaykitProfile()) } + profilePublisher: @escaping ProfilePublisher = { try await PubkyService.publishPaykitProfile($0.toPaykitProfile()) }, + avatarUploader: @escaping AvatarUploader = { + try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1) + } ) { self.remoteProfileResolver = remoteProfileResolver self.profilePublisher = profilePublisher + self.avatarUploader = avatarUploader cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) cachedProfileOwner = UserDefaults.standard.string(forKey: Self.cachedProfileOwnerKey) @@ -345,11 +351,12 @@ class PubkyProfileManager: ObservableObject { return try decoder.decode(HomegateResponse.self, from: data) } - /// Upload an avatar image to the user's homeserver blob storage. Returns the `pubky://` URI. - func uploadAvatar(image: UIImage) async throws -> String { + /// Upload an avatar image to the user's homeserver blob storage. Returns the `pubky://` URI. With `expectedIdentity`, + /// the upload writes nothing and fails unless that identity is still signed in, with a live session, when it runs. + func uploadAvatar(image: UIImage, expectedIdentity: String? = nil) async throws -> String { _ = try activeSessionSecret() let imageData = try compressAvatar(image) - return try await PubkyService.uploadProfileAvatar(bytes: imageData, contentType: "image/jpeg") + return try await avatarUploader(imageData, expectedIdentity) } private func compressAvatar(_ image: UIImage, maxSize: CGFloat = 400) throws -> Data { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index a443b10a8..8287a8f8a 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -10,6 +10,8 @@ enum PubkyServiceError: LocalizedError { case authFailed(String) case profileNotFound case activeSubscription(endsAt: Date?) + /// A write was for an identity that is no longer the signed-in one, so it wrote nothing. + case identityChanged var errorDescription: String? { switch self { @@ -23,6 +25,8 @@ enum PubkyServiceError: LocalizedError { return "Profile not found" case .activeSubscription: return "Contact has an active subscription" + case .identityChanged: + return "The Pubky identity changed" } } } @@ -278,8 +282,8 @@ enum PubkyService { _ = try await PaykitSdkService.shared.publishPaykitProfile(profile) } - static func uploadProfileAvatar(bytes: Data, contentType: String) async throws -> String { - try await PaykitSdkService.shared.uploadProfileAvatar(bytes: bytes, contentType: contentType) + static func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String? = nil) async throws -> String { + try await PaykitSdkService.shared.uploadProfileAvatar(bytes: bytes, contentType: contentType, expectedIdentity: expectedIdentity) } static func deletePaykitProfile() async throws { @@ -297,13 +301,14 @@ enum PubkyService { } static func saveContact(publicKey: String, label: String?, receiverPaths: [String]? = nil, - restorePrivateConnection: Bool = false) async throws -> Paykit.ContactRecord + restorePrivateConnection: Bool = false, expectedIdentity: String? = nil) async throws -> Paykit.ContactRecord { try await PaykitSdkService.shared.saveContact( publicKey: publicKey, label: label, receiverPaths: receiverPaths, - restorePrivateConnection: restorePrivateConnection + restorePrivateConnection: restorePrivateConnection, + expectedIdentity: expectedIdentity ) } @@ -689,13 +694,19 @@ actor PaykitSdkService { } } + /// With `expectedIdentity`, it saves only while that identity is signed in, checked in the same locked operation as the + /// save, so a save that a sign-out or another identity's sign-in overtakes writes nothing and throws `identityChanged`. func saveContact( publicKey: String, label: String?, receiverPaths: [String]? = nil, - restorePrivateConnection: Bool = false + restorePrivateConnection: Bool = false, + expectedIdentity: String? = nil ) async throws -> Paykit.ContactRecord { try await withStateRevisionTracking { sdk in + if let expectedIdentity { + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + } let existing = try await sdk.contactRecord(publicKey: publicKey) guard restorePrivateConnection || existing != nil else { throw PubkyServiceError.profileNotFound } let existingPaths = existing?.receiverPaths ?? [] @@ -1309,6 +1320,16 @@ actor PaykitSdkService { return try await handle().identityStatus()?.publicKey } + /// Throws `identityChanged` unless `expectedIdentity` is the identity `sdk` is signed in as. Run it inside the locked + /// operation of the write it guards: sign-in and sign-out also take `operationLock`, so neither can land between this + /// check and the write. + private nonisolated static func requireSignedInIdentity(_ expectedIdentity: String, in sdk: PaykitSdk) async throws { + let signedInIdentity = try await sdk.identityStatus()?.publicKey + guard PubkyPublicKeyFormat.matches(signedInIdentity, expectedIdentity) else { + throw PubkyServiceError.identityChanged + } + } + private nonisolated static func publicKeysMatch(_ lhs: String?, _ rhs: String) -> Bool { guard let lhs, let normalizedLhs = try? Paykit.normalizePubkyPublicKey(value: lhs), diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 3b5a9cc1a..b68b271e4 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -244,6 +244,7 @@ struct ContactDetailView: View { let change = contactsManager.updateContactTags( publicKey: publicKey, shownProfile: current, + expectedIdentity: session.publicKey, isSessionCurrent: { pubkyProfile.currentSession == session }, transform: transform ) diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index c06c57460..4e30eb5df 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -141,7 +141,8 @@ struct EditContactView: View { // MARK: - Save /// The save belongs to the Pubky session Save was tapped in. Once that session ends, even while the save still waits - /// for the contact's profile or uploads the avatar, it stops quietly, with no toast and no navigation. + /// for the contact's profile or uploads the avatar, it stops quietly, with no toast and no navigation. The avatar upload + /// and the contact save each write nothing once another identity is signed in. private func saveContact() async { let pubkyProfile = pubkyProfile guard !form.trimmedName.isEmpty, let session = pubkyProfile.currentSession else { return } @@ -152,11 +153,12 @@ struct EditContactView: View { do { let savedProfile = try await contactsManager.saveContactEdit( publicKey: publicKey, + expectedIdentity: session.publicKey, isSessionCurrent: { pubkyProfile.currentSession == session } ) { fillFormFromContact() let uploadedImageUrl = if let avatarImage { - try await pubkyProfile.uploadAvatar(image: avatarImage) + try await pubkyProfile.uploadAvatar(image: avatarImage, expectedIdentity: session.publicKey) } else { form.imageUrl } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index b4064b49c..117a9f9d8 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1157,7 +1157,7 @@ final class ContactsManagerTests: XCTestCase { let savedLabels = SavedContactLabels() let manager = ContactsManager( fetchRemoteProfile: { _, _ in throw profileTransportError }, - saveContactLabel: { await savedLabels.save($0, label: $1) } + saveContactLabel: { publicKey, label, _ in await savedLabels.save(publicKey, label: label) } ) let bulk = HeldProfileLookups(profiles: [:]) await bulk.hold() @@ -1176,9 +1176,11 @@ final class ContactsManagerTests: XCTestCase { // The user adds two tags while it is still held. let shown = try XCTUnwrap(manager.contacts.first?.profile) - let first = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown, isSessionCurrent: { true }) { $0 + ["friend"] } + let first = manager.updateContactTags( + publicKey: contactProfileKey, shownProfile: shown, expectedIdentity: "owner", isSessionCurrent: { true } + ) { $0 + ["friend"] } let second = manager.updateContactTags( - publicKey: contactProfileKey, shownProfile: shown.withTags(["friend"]), isSessionCurrent: { true } + publicKey: contactProfileKey, shownProfile: shown.withTags(["friend"]), expectedIdentity: "owner", isSessionCurrent: { true } ) { $0 + ["work"] } await interactive.release() await screenLookup.value @@ -1210,12 +1212,12 @@ final class ContactsManagerTests: XCTestCase { let message = nextIdentityHasContact ? "when the next identity saved the same contact" : "when it has no such contact" ContactsManager.restoreContactProfileOverrides(nil) let session = TestPubkySession() - let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let store = SignedInContactStore(identity: "owner-a", savedKeys: [contactProfileKey]) let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) await interactive.hold() let manager = ContactsManager( fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, - saveContactLabel: { try await store.save($0, label: $1) } + saveContactLabel: { try await store.save($0, label: $1, expectedIdentity: $2) } ) let bulk = HeldProfileLookups(profiles: [:]) await bulk.hold() @@ -1223,11 +1225,12 @@ final class ContactsManagerTests: XCTestCase { try await manager.loadContacts(for: "owner-a", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) let shown = try XCTUnwrap(manager.contacts.first?.profile) - let first = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown, isSessionCurrent: session.check()) { - $0 + ["friend"] - } + let first = manager.updateContactTags( + publicKey: contactProfileKey, shownProfile: shown, expectedIdentity: "owner-a", isSessionCurrent: session.check() + ) { $0 + ["friend"] } let second = manager.updateContactTags( - publicKey: contactProfileKey, shownProfile: shown.withTags(["friend"]), isSessionCurrent: session.check() + publicKey: contactProfileKey, shownProfile: shown.withTags(["friend"]), expectedIdentity: "owner-a", + isSessionCurrent: session.check() ) { $0 + ["work"] } // The first change takes the contact's lookup over, and the lookup is held. while await interactive.heldCount < 1 { @@ -1240,7 +1243,7 @@ final class ContactsManagerTests: XCTestCase { ContactsManager.restoreContactProfileOverrides(nil) // Another identity signs in and loads its contacts, whose own lookups are held. session.change() - await store.signIn(savedKeys: nextIdentityHasContact ? [contactProfileKey] : []) + await store.signIn(identity: "owner-b", savedKeys: nextIdentityHasContact ? [contactProfileKey] : []) let nextRecords = nextIdentityHasContact ? [unprofiledRecord(key: contactProfileKey, label: "Bob's label")] : [] let nextBulk = HeldProfileLookups(profiles: [:]) await nextBulk.hold() @@ -1279,12 +1282,12 @@ final class ContactsManagerTests: XCTestCase { let message = resetsContacts ? "after a reset" : "once the session ends" ContactsManager.restoreContactProfileOverrides(nil) let session = TestPubkySession() - let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let store = SignedInContactStore(identity: "owner", savedKeys: [contactProfileKey]) let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) await interactive.hold() let manager = ContactsManager( fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, - saveContactLabel: { try await store.save($0, label: $1) } + saveContactLabel: { try await store.save($0, label: $1, expectedIdentity: $2) } ) let bulk = HeldProfileLookups(profiles: [:]) await bulk.hold() @@ -1295,6 +1298,7 @@ final class ContactsManagerTests: XCTestCase { let stale = manager.updateContactTags( publicKey: contactProfileKey, shownProfile: shown, + expectedIdentity: "owner", isSessionCurrent: resetsContacts ? { true } : session.check() ) { $0 + ["friend"] } while await interactive.heldCount < 1 { @@ -1309,9 +1313,9 @@ final class ContactsManagerTests: XCTestCase { let nextRecords = [contactRecord(key: contactProfileKey, name: "Alice")] try await manager.loadContacts(for: "owner", fetchContactRecords: { nextRecords }, fetchRemoteProfile: { _ in nil }) let next = try XCTUnwrap(manager.contacts.first?.profile) - let change = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: next, isSessionCurrent: { true }) { - $0 + ["new"] - } + let change = manager.updateContactTags( + publicKey: contactProfileKey, shownProfile: next, expectedIdentity: "owner", isSessionCurrent: { true } + ) { $0 + ["new"] } let nextSaved = expectation(description: "The next session's change saves while the old lookup is still held") Task { _ = await change.result @@ -1342,26 +1346,28 @@ final class ContactsManagerTests: XCTestCase { } } - /// The save itself was admitted, so it runs, but a sign-out and another identity's sign-in land before it returns. The - /// save may then succeed or fail; either way nothing is published for the next identity. + /// The save itself was admitted and waits for the SDK lock, but a sign-out and another identity's sign-in land before it + /// gets it: after the last check before the save, before the SDK writes. The next identity may have saved a contact with + /// the same key, which the SDK would accept a save for, or not. Either way the SDK refuses the save, since it is for the + /// identity that signed out, so it writes nothing and nothing is published for the next identity. func testTagSaveThatASessionChangeOvertakesPublishesNothing() async throws { let savedOverrides = ContactsManager.backupContactProfileOverrides() addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } - for heldSaveSucceeds in [true, false] { - let message = heldSaveSucceeds ? "after a save that succeeds" : "after a save that fails" + for nextIdentityHasContact in [true, false] { + let message = nextIdentityHasContact ? "when the next identity saved the same contact" : "when it has no such contact" ContactsManager.restoreContactProfileOverrides(nil) let session = TestPubkySession() - let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let store = SignedInContactStore(identity: "owner-a", savedKeys: [contactProfileKey]) await store.hold() - let manager = ContactsManager(saveContactLabel: { try await store.save($0, label: $1) }) + let manager = ContactsManager(saveContactLabel: { try await store.save($0, label: $1, expectedIdentity: $2) }) let records = [contactRecord(key: contactProfileKey, name: "Alice")] try await manager.loadContacts(for: "owner-a", fetchContactRecords: { records }, fetchRemoteProfile: { _ in nil }) let shown = try XCTUnwrap(manager.contacts.first?.profile) - let change = manager.updateContactTags(publicKey: contactProfileKey, shownProfile: shown, isSessionCurrent: session.check()) { - $0 + ["friend"] - } + let change = manager.updateContactTags( + publicKey: contactProfileKey, shownProfile: shown, expectedIdentity: "owner-a", isSessionCurrent: session.check() + ) { $0 + ["friend"] } while await store.heldCount < 1 { await Task.yield() } @@ -1370,7 +1376,7 @@ final class ContactsManagerTests: XCTestCase { manager.reset() ContactsManager.restoreContactProfileOverrides(nil) session.change() - await store.signIn(savedKeys: heldSaveSucceeds ? [contactProfileKey] : []) + await store.signIn(identity: "owner-b", savedKeys: nextIdentityHasContact ? [contactProfileKey] : []) let nextRecords = [contactRecord(key: contactProfileKey, name: "Bob's Alice")] try await manager.loadContacts(for: "owner-b", fetchContactRecords: { nextRecords }, fetchRemoteProfile: { _ in nil }) @@ -1381,22 +1387,118 @@ final class ContactsManagerTests: XCTestCase { XCTFail("A save the sign-out overtook must not report an error \(message): \(error)") } + let saved = await store.savedLabels + XCTAssertEqual(saved, [], "Nothing is written to the next identity's contacts \(message)") + let refused = await store.refusedIdentities + XCTAssertEqual(refused, ["owner-a"], "The SDK refuses the save for the identity that signed out \(message)") XCTAssertNil(ContactsManager.backupContactProfileOverrides(), "No override is written back \(message)") XCTAssertEqual(manager.contacts.map(\.displayName), ["Bob's Alice"], "The next identity's row is unchanged \(message)") XCTAssertEqual(manager.contacts.map(\.profile.tags), [[]], message) } } + /// Like a tag change, an edit's save that waits for the SDK lock while a sign-out and another identity's sign-in land is + /// refused for the identity that signed out, writes nothing and reports nothing. + func testContactEditThatASessionChangeOvertakesWhileItSavesWritesNothing() async throws { + let savedOverrides = ContactsManager.backupContactProfileOverrides() + addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } + ContactsManager.restoreContactProfileOverrides(nil) + let session = TestPubkySession() + let store = SignedInContactStore(identity: "owner-a", savedKeys: [contactProfileKey]) + await store.hold() + let manager = ContactsManager(saveContactLabel: { try await store.save($0, label: $1, expectedIdentity: $2) }) + let records = [contactRecord(key: contactProfileKey, name: "Alice")] + try await manager.loadContacts(for: "owner-a", fetchContactRecords: { records }, fetchRemoteProfile: { _ in nil }) + + let screen = ContactEditScreen(manager: manager, publicKey: contactProfileKey) + screen.edit { + $0.name = "My Alice" + $0.tags = ["friend"] + } + let save = Task { + try await manager.saveContactEdit(publicKey: contactProfileKey, expectedIdentity: "owner-a", isSessionCurrent: session.check()) { + try await screen.makeEdit() + } + } + while await store.heldCount < 1 { + await Task.yield() + } + + session.change() + manager.reset() + ContactsManager.restoreContactProfileOverrides(nil) + session.change() + await store.signIn(identity: "owner-b", savedKeys: [contactProfileKey]) + let nextRecords = [contactRecord(key: contactProfileKey, name: "Bob's Alice")] + try await manager.loadContacts(for: "owner-b", fetchContactRecords: { nextRecords }, fetchRemoteProfile: { _ in nil }) + + await store.release() + var savedProfile: Bitkit.PubkyProfile? + do { + savedProfile = try await save.value + } catch { + XCTFail("An edit the sign-out overtook must not report an error, so no toast shows: \(error)") + } + + XCTAssertNil(savedProfile, "Nothing is reported saved, so no toast shows and the screen does not navigate") + let saved = await store.savedLabels + XCTAssertEqual(saved, [], "Nothing is written to the next identity's contact") + let refused = await store.refusedIdentities + XCTAssertEqual(refused, ["owner-a"], "The SDK refuses the save for the identity that signed out") + XCTAssertNil(ContactsManager.backupContactProfileOverrides(), "No override is written back") + XCTAssertEqual(manager.contacts.map(\.displayName), ["Bob's Alice"]) + XCTAssertEqual(manager.contacts.map(\.profile.tags), [[]]) + } + + /// The SDK refuses a save because another identity is signed in while the session check still holds, as it can for a + /// sign-in the session check has not seen yet. A tag change and an edit treat the refusal as stale and drop it quietly. + func testContactSaveTheSdkRefusesForAnotherIdentityIsDroppedQuietly() async throws { + let savedOverrides = ContactsManager.backupContactProfileOverrides() + addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } + ContactsManager.restoreContactProfileOverrides(nil) + let store = SignedInContactStore(identity: "owner-b", savedKeys: [contactProfileKey]) + let manager = ContactsManager(saveContactLabel: { try await store.save($0, label: $1, expectedIdentity: $2) }) + let records = [contactRecord(key: contactProfileKey, name: "Alice")] + try await manager.loadContacts(for: "owner-a", fetchContactRecords: { records }, fetchRemoteProfile: { _ in nil }) + let shown = try XCTUnwrap(manager.contacts.first?.profile) + + let change = manager.updateContactTags( + publicKey: contactProfileKey, shownProfile: shown, expectedIdentity: "owner-a", isSessionCurrent: { true } + ) { $0 + ["friend"] } + do { + try await change.value + } catch { + XCTFail("A refused tag change must not report an error, so no toast shows: \(error)") + } + var savedProfile: Bitkit.PubkyProfile? + do { + savedProfile = try await manager.saveContactEdit(publicKey: contactProfileKey, expectedIdentity: "owner-a", isSessionCurrent: { true }) { + ContactEdit(name: "My Alice", bio: "", imageUrl: nil, links: [], tags: ["friend"]) + } + } catch { + XCTFail("A refused edit must not report an error, so no toast shows: \(error)") + } + + XCTAssertNil(savedProfile, "Nothing is reported saved, so Edit Contact shows no toast and does not navigate") + let refused = await store.refusedIdentities + XCTAssertEqual(refused, ["owner-a", "owner-a"]) + let saved = await store.savedLabels + XCTAssertEqual(saved, []) + XCTAssertNil(ContactsManager.backupContactProfileOverrides(), "No override is written") + XCTAssertEqual(manager.contacts.map(\.displayName), ["Alice"], "The row is unchanged") + XCTAssertEqual(manager.contacts.map(\.profile.tags), [[]]) + } + func testContactEditWaitsForTheContactsLookupAndSavesOverTheProfileItFinds() async throws { let savedOverrides = ContactsManager.backupContactProfileOverrides() addTeardownBlock { ContactsManager.restoreContactProfileOverrides(savedOverrides) } ContactsManager.restoreContactProfileOverrides(nil) - let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let store = SignedInContactStore(identity: "owner", savedKeys: [contactProfileKey]) let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) await interactive.hold() let manager = ContactsManager( fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, - saveContactLabel: { try await store.save($0, label: $1) } + saveContactLabel: { try await store.save($0, label: $1, expectedIdentity: $2) } ) let bulk = HeldProfileLookups(profiles: [:]) await bulk.hold() @@ -1406,7 +1508,7 @@ final class ContactsManagerTests: XCTestCase { let screen = ContactEditScreen(manager: manager, publicKey: contactProfileKey) screen.edit { $0.tags = ["friend"] } let save = Task { - try await manager.saveContactEdit(publicKey: contactProfileKey, isSessionCurrent: { true }) { + try await manager.saveContactEdit(publicKey: contactProfileKey, expectedIdentity: "owner", isSessionCurrent: { true }) { try await screen.makeEdit() } } @@ -1444,12 +1546,12 @@ final class ContactsManagerTests: XCTestCase { let message = nextIdentityHasContact ? "when the next identity saved the same contact" : "when it has no such contact" ContactsManager.restoreContactProfileOverrides(nil) let session = TestPubkySession() - let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let store = SignedInContactStore(identity: "owner-a", savedKeys: [contactProfileKey]) let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) await interactive.hold() let manager = ContactsManager( fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, - saveContactLabel: { try await store.save($0, label: $1) } + saveContactLabel: { try await store.save($0, label: $1, expectedIdentity: $2) } ) let bulk = HeldProfileLookups(profiles: [:]) await bulk.hold() @@ -1465,7 +1567,9 @@ final class ContactsManagerTests: XCTestCase { $0.tags = ["friend"] } let save = Task { - try await manager.saveContactEdit(publicKey: contactProfileKey, isSessionCurrent: session.check()) { + try await manager.saveContactEdit( + publicKey: contactProfileKey, expectedIdentity: "owner-a", isSessionCurrent: session.check() + ) { try await screen.makeEdit() } } @@ -1479,7 +1583,7 @@ final class ContactsManagerTests: XCTestCase { ContactsManager.restoreContactProfileOverrides(nil) // Another identity signs in and loads its contacts, whose own lookups are held. session.change() - await store.signIn(savedKeys: nextIdentityHasContact ? [contactProfileKey] : []) + await store.signIn(identity: "owner-b", savedKeys: nextIdentityHasContact ? [contactProfileKey] : []) let nextRecords = nextIdentityHasContact ? [unprofiledRecord(key: contactProfileKey, label: "Bob's label")] : [] let nextBulk = HeldProfileLookups(profiles: [:]) await nextBulk.hold() @@ -1522,14 +1626,14 @@ final class ContactsManagerTests: XCTestCase { let message = "\(resetsContacts ? "after a reset" : "once the session ends") during the \(wait)" ContactsManager.restoreContactProfileOverrides(nil) let session = TestPubkySession() - let store = SignedInContactStore(savedKeys: [contactProfileKey]) + let store = SignedInContactStore(identity: "owner", savedKeys: [contactProfileKey]) let interactive = HeldProfileLookups(publishedProfiles: [publishedContactProfile]) if wait == "lookup" { await interactive.hold() } let manager = ContactsManager( fetchRemoteProfile: { publicKey, _ in try await interactive.fetch(publicKey) }, - saveContactLabel: { try await store.save($0, label: $1) } + saveContactLabel: { try await store.save($0, label: $1, expectedIdentity: $2) } ) let bulk = HeldProfileLookups(profiles: [:]) await bulk.hold() @@ -1556,6 +1660,7 @@ final class ContactsManagerTests: XCTestCase { let save = Task { try await manager.saveContactEdit( publicKey: contactProfileKey, + expectedIdentity: "owner", isSessionCurrent: resetsContacts ? { true } : session.check() ) { guard wait != "lookup" else { return try await screen.makeEdit() } @@ -2102,10 +2207,14 @@ private final class ContactEditScreen { private struct ContactNotSavedError: Error {} /// Stands in for the SDK's contact storage of whichever identity is signed in. Like the SDK, it saves a label only for a -/// contact that identity has saved. It can hold saves until released. +/// contact that identity has saved, and only while the identity the save is for is signed in, checked together with the +/// write. It can hold saves, as the SDK lock would, until released. private actor SignedInContactStore { + private var identity: String private var savedKeys: Set private(set) var savedLabels: [String] = [] + /// The identity each save it refused was for. + private(set) var refusedIdentities: [String] = [] private var isHolding = false private var held: [CheckedContinuation] = [] @@ -2113,11 +2222,13 @@ private actor SignedInContactStore { held.count } - init(savedKeys: Set) { + init(identity: String, savedKeys: Set) { + self.identity = identity self.savedKeys = savedKeys } - func signIn(savedKeys: Set) { + func signIn(identity: String, savedKeys: Set) { + self.identity = identity self.savedKeys = savedKeys } @@ -2131,10 +2242,14 @@ private actor SignedInContactStore { held.removeAll() } - func save(_ publicKey: String, label: String) async throws { + func save(_ publicKey: String, label: String, expectedIdentity: String) async throws { if isHolding { await withCheckedContinuation { held.append($0) } } + guard expectedIdentity == identity else { + refusedIdentities.append(expectedIdentity) + throw PubkyServiceError.identityChanged + } guard savedKeys.contains(publicKey) else { throw ContactNotSavedError() } savedLabels.append(label) } diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift new file mode 100644 index 000000000..91f6ab0a7 --- /dev/null +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -0,0 +1,249 @@ +@testable import Bitkit +import Paykit +import XCTest + +/// Writes made for one identity check, in the same locked SDK operation as the write, that the identity is still signed +/// in, so a sign-out and another identity's sign-in that land while the write waits make it write nothing. +final class PaykitSdkIdentityCheckTests: XCTestCase { + func testContactSaveForAnIdentityThatIsNoLongerSignedInWritesNothing() async throws { + for signedIn in [identityB, nil] { + for restorePrivateConnection in [false, true] { + let message = "\(signedIn == nil ? "after a sign-out" : "with another identity signed in"), restore \(restorePrivateConnection)" + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = signedIn + let service = PaykitSdkService(sdkFactory: { sdk }) + + do { + _ = try await service.saveContact( + publicKey: contactKey, + label: "Alice", + restorePrivateConnection: restorePrivateConnection, + expectedIdentity: identityA + ) + XCTFail("Expected the save to be refused \(message)") + } catch PubkyServiceError.identityChanged { + } catch { + XCTFail("Expected identityChanged \(message), got \(error)") + } + XCTAssertEqual(sdk.writes, [], "Nothing is written \(message)") + XCTAssertEqual(sdk.contactReads, 0, "It stops before it reads the next identity's contact \(message)") + } + } + } + + func testContactSaveForTheSignedInIdentitySaves() async throws { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + + let saved = try await service.saveContact(publicKey: contactKey, label: "Alice", expectedIdentity: bareIdentityA) + _ = try await service.saveContact(publicKey: contactKey, label: "Unbound") + + XCTAssertEqual(saved.label, "Alice", "The identity matches in either key form") + XCTAssertEqual(sdk.writes, ["save:Alice", "save:Unbound"], "A save for no identity saves for whichever one is signed in") + } + + /// The caller checked its session and started the write, which waits for the SDK lock while a sign-out and another + /// identity's sign-in land. Only then does the write get the lock, so it must check the identity there. + func testWriteThatAnIdentityChangeOvertakesWhileItWaitsForTheSdkLockWritesNothing() async throws { + let writes: [(name: String, write: @Sendable (PaykitSdkService) async throws -> Void, isRefusal: @Sendable (Error) -> Bool)] = [ + ("contact save", { service in + _ = try await service.saveContact(publicKey: contactKey, label: "Alice", expectedIdentity: identityA) + }, { error in + if case .identityChanged? = error as? PubkyServiceError { return true } + return false + }), + ("avatar upload", { service in + _ = try await service.uploadProfileAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: identityA) + }, { error in + (error as? PaykitPaymentRequestError) == .requestUnavailable + }), + ] + for testCase in writes { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + await sdk.contactRecordsGate.close() + let lockHolder = Task { try await service.contactRecords() } + try await sdk.contactRecordsStarted.waitForEntries(count: 1) + + let write = Task { try await testCase.write(service) } + try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(sdk.writes, [], "\(testCase.name): the write waits for the SDK lock") + sdk.identity = identityB + + await sdk.contactRecordsGate.open() + _ = try await lockHolder.value + switch await write.result { + case .success: + XCTFail("\(testCase.name): expected the write to be refused") + case let .failure(error): + XCTAssertTrue(testCase.isRefusal(error), "\(testCase.name): unexpected error \(error)") + } + XCTAssertEqual(sdk.writes, [], "\(testCase.name): nothing is written for the next identity") + } + } + + /// A sign-out queues for the SDK lock right behind a contact save. The lock is handed over in order, so the save runs + /// first and saves while its identity is signed in. Were its identity checked in a locked operation of its own, the + /// sign-out would take the lock between that check and the save, and the save would land after it. + func testSignOutQueuedBehindAContactSaveCannotLandBetweenItsCheckAndItsWrite() async throws { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + await sdk.contactRecordsGate.close() + let lockHolder = Task { try await service.contactRecords() } + try await sdk.contactRecordsStarted.waitForEntries(count: 1) + + let save = Task { try await service.saveContact(publicKey: contactKey, label: "Alice", expectedIdentity: identityA) } + try await Task.sleep(for: .milliseconds(50)) + let signOut = Task { try await service.signOut() } + try await Task.sleep(for: .milliseconds(50)) + + await sdk.contactRecordsGate.open() + _ = try await lockHolder.value + try await signOut.value + let saveResult = await save.result + + XCTAssertNil(sdk.identity, "The sign-out ran") + XCTAssertNoThrow(try saveResult.get(), "The save got the lock first, while its identity was signed in") + XCTAssertEqual(sdk.writes, ["save:Alice"]) + XCTAssertEqual(sdk.identitiesAtWrites, [identityA], "Nothing is written once the identity signed out") + } +} + +private let identityA = "pubky8qd4tbz3hyafi7h7hoqwd5hm7tsaz1n7txcyojmd4kxf9xp7mgro" +private let bareIdentityA = "8qd4tbz3hyafi7h7hoqwd5hm7tsaz1n7txcyojmd4kxf9xp7mgro" +private let identityB = "pubkyc1nbnzsfgm1g1rf9um6nh5mdtdhq8mz5i5o4r6g8x4qzi3uqhdmo" +private let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + +/// Holds every call that waits on it while closed. +private actor IdentityCheckGate { + private var isOpen = true + private var waiters: [CheckedContinuation] = [] + + func close() { + isOpen = false + } + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + isOpen = true + waiters.forEach { $0.resume() } + waiters.removeAll() + } +} + +private struct IdentityCheckTimeout: Error, CustomStringConvertible { + let description: String +} + +/// Counts calls and lets a test wait until enough have started, failing instead of hanging. +private actor IdentityCheckCallLog { + private(set) var count = 0 + + func record() { + count += 1 + } + + func waitForEntries(count expected: Int) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while count < expected { + guard ContinuousClock.now < deadline else { + throw IdentityCheckTimeout(description: "Timed out waiting for \(expected) calls; saw \(count)") + } + try await Task.sleep(for: .milliseconds(5)) + } + } +} + +/// An SDK signed in as `identity`, which a test changes as a sign-out and another identity's sign-in would. A contact +/// records read waits on its gate, keeping the SDK lock meanwhile. It records each write that reaches it. +private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { + private let lock = NSLock() + private var signedInIdentity: String? + private var recordedWrites: [String] = [] + private var recordedIdentitiesAtWrites: [String?] = [] + private var recordedContactReads = 0 + let contactRecordsGate = IdentityCheckGate() + let contactRecordsStarted = IdentityCheckCallLog() + + var identity: String? { + get { lock.withLock { signedInIdentity } } + set { lock.withLock { signedInIdentity = newValue } } + } + + var writes: [String] { + lock.withLock { recordedWrites } + } + + /// The identity signed in as each write landed. + var identitiesAtWrites: [String?] { + lock.withLock { recordedIdentitiesAtWrites } + } + + var contactReads: Int { + lock.withLock { recordedContactReads } + } + + private func recordWrite(_ write: String) { + lock.withLock { + recordedWrites.append(write) + recordedIdentitiesAtWrites.append(signedInIdentity) + } + } + + override func identityStatus() async throws -> IdentityStatus? { + IdentityStatus(publicKey: identity, liveSessionAvailable: identity != nil) + } + + override func backupStateRevision() async throws -> String { + "revision" + } + + override func signOut() async throws -> IdentityStatus { + identity = nil + return IdentityStatus(publicKey: nil, liveSessionAvailable: false) + } + + override func contactRecords() async throws -> [ContactRecord] { + await contactRecordsStarted.record() + await contactRecordsGate.wait() + return [] + } + + override func contactRecord(publicKey: String) async throws -> ContactRecord? { + lock.withLock { recordedContactReads += 1 } + return Self.record(publicKey: publicKey, label: "Contact") + } + + override func linkedPeers() async throws -> [LinkedPeerRecord] { + [] + } + + override func saveContact(update: ContactUpdate) async throws -> ContactRecord { + recordWrite("save:\(update.label ?? "")") + return Self.record(publicKey: update.publicKey, label: update.label) + } + + override func uploadProfileAvatar(bytes: Data, contentType _: String) async throws -> PaykitBlobRecord { + recordWrite("upload") + return PaykitBlobRecord( + publicKey: identity ?? "", path: "/pub/paykit/blobs/avatar.jpg", uri: "pubky://avatar", + sizeBytes: UInt64(bytes.count), updatedAt: "2026-10-02T00:00:00Z" + ) + } + + private static func record(publicKey: String, label: String?) -> ContactRecord { + ContactRecord( + publicKey: publicKey, receiverPaths: [PaykitReceiverPath.wallet], label: label, profile: nil, + profileFetchedAt: nil, createdAt: "2026-10-02T00:00:00Z", updatedAt: "2026-10-02T00:00:00Z", + publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, + publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil + ) + } +} diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 59018060c..10b1bed66 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1,6 +1,7 @@ @testable import Bitkit import class Paykit.PubkySessionAccess import struct Paykit.PubkySessionBootstrapResult +import UIKit import XCTest final class PubkyProfileManagerTests: XCTestCase { @@ -1337,6 +1338,24 @@ final class PubkyProfileManagerTests: XCTestCase { } } + // MARK: - Avatar uploads + + /// Edit Contact uploads a new avatar for the identity Save was tapped in. The upload hands that identity to the SDK, + /// which writes nothing once another identity is signed in. + @MainActor + func testAvatarUploadIsForTheIdentityItWasStartedFor() async throws { + try await withStoredSessionSecret { + let uploads = AvatarUploads() + let manager = PubkyProfileManager(avatarUploader: { try await uploads.upload($0, expectedIdentity: $1) }) + + let uri = try await manager.uploadAvatar(image: makeAvatarImage(), expectedIdentity: ringKeyA) + + XCTAssertEqual(uri, uploadedAvatarUri) + let identities = await uploads.expectedIdentities + XCTAssertEqual(identities, [ringKeyA]) + } + } + // MARK: - Cached profile preview @MainActor @@ -2508,6 +2527,28 @@ final class PubkyProfileManagerTests: XCTestCase { ) } + /// Runs `body` with a stored Pubky session secret, restoring whatever was stored before. + @MainActor + private func withStoredSessionSecret(_ body: () async throws -> Void) async throws { + let savedSession = try Keychain.load(key: .paykitSession) + defer { + if let savedSession { + try? Keychain.upsert(key: .paykitSession, data: savedSession) + } else { + try? Keychain.delete(key: .paykitSession) + } + } + try Keychain.upsert(key: .paykitSession, data: Data("saved-session".utf8)) + try await body() + } + + private func makeAvatarImage() -> UIImage { + UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { context in + UIColor.orange.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + } + } + /// Profile commits and clears write these keys to the standard defaults. @MainActor private func withRestoredProfileDefaults(_ body: () async throws -> Void) async rethrows { @@ -2710,6 +2751,18 @@ private actor RemoteProfileStub { } } +private let uploadedAvatarUri = "pubky://uploaded/avatar.jpg" + +/// Stands in for the SDK's avatar upload: records the identity each upload is for. +private actor AvatarUploads { + private(set) var expectedIdentities: [String?] = [] + + func upload(_: Data, expectedIdentity: String?) -> String { + expectedIdentities.append(expectedIdentity) + return uploadedAvatarUri + } +} + /// Stands in for publishing the signed-in profile: records each publication and can hold them until released. A wait for /// held publications that outlasts the deadline fails the test instead of hanging the suite. private actor ProfilePublications { From 6bb0f2d13049d5c3d236b503489bbe3718f9ba0e Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 22:19:44 +0200 Subject: [PATCH 77/79] fix: bind a profile edit to its session before it saves Edit Profile uploaded a new avatar and then saved the profile without tying either to the session Save was tapped in. A save that a sign-out and another identity's sign-in overtook changed nothing locally, but the screen still showed the saved toast and navigated back, the upload could land for the next identity, and the publication could overwrite the next identity's profile while it waited for the Paykit lock. PubkyProfileManager.saveProfile now takes the avatar image, binds the edit to the current session before uploading, and returns whether it saved. The upload and the profile publication both carry the session's identity, which PaykitSdkService checks under the operation lock, so neither writes once another identity is signed in. A stale or refused save returns false without an error, leaves the profile and setup state alone, and Edit Profile then shows no toast and stays put. --- Bitkit/Managers/PubkyProfileManager.swift | 101 +++++-- Bitkit/Services/PubkyService.swift | 14 +- Bitkit/Views/Profile/EditProfileView.swift | 12 +- BitkitTests/PaykitSdkIdentityCheckTests.swift | 37 +++ BitkitTests/PubkyProfileManagerTests.swift | 252 +++++++++++++++++- 5 files changed, 372 insertions(+), 44 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 719a0a57b..38b694e92 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -56,7 +56,7 @@ class PubkyProfileManager: ObservableObject { } typealias RemoteProfileResolver = @Sendable (String) async throws -> PubkyProfile - typealias ProfilePublisher = @Sendable (PubkyProfileData) async throws -> Void + typealias ProfilePublisher = @Sendable (_ profile: PubkyProfileData, _ expectedIdentity: String?) async throws -> Void typealias AvatarUploader = @Sendable (_ jpegData: Data, _ expectedIdentity: String?) async throws -> String private enum SessionInitializationMode { @@ -116,7 +116,9 @@ class PubkyProfileManager: ObservableObject { init( remoteProfileResolver: @escaping RemoteProfileResolver = { try await PubkyProfileManager.resolveRemoteProfile(publicKey: $0) }, - profilePublisher: @escaping ProfilePublisher = { try await PubkyService.publishPaykitProfile($0.toPaykitProfile()) }, + profilePublisher: @escaping ProfilePublisher = { + try await PubkyService.publishPaykitProfile($0.toPaykitProfile(), expectedIdentity: $1) + }, avatarUploader: @escaping AvatarUploader = { try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1) } @@ -781,47 +783,86 @@ class PubkyProfileManager: ObservableObject { } } + /// Saves an edit to the signed-in profile, uploading `avatarImage` first when there is one, and returns whether it was + /// saved. The edit belongs to the session it was started in, bound before it uploads anything. Once that session ends + /// or changes, or nothing is signed in, it stops quietly and returns false: it reports no error, the upload and the + /// publication each write nothing once another identity is signed in, and the profile and setup state stay as they are. + @discardableResult func saveProfile( name: String, bio: String, links: [PubkyProfileLink], tags: [String] = [], - newImageUrl: String? = nil - ) async throws { + avatarImage: UIImage? = nil + ) async throws -> Bool { + guard let session = currentSession else { return false } _ = try activeSessionSecret() - try await publishProfileEdit(name: name, bio: bio, links: links, tags: tags, newImageUrl: newImageUrl) + return try await saveProfileEdit(name: name, bio: bio, links: links, tags: tags, avatarImage: avatarImage, session: session) + } + + private func saveProfileEdit( + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String], + avatarImage: UIImage?, + session: SignedInSession + ) async throws -> Bool { + var newImageUrl: String? + if let avatarImage { + do { + newImageUrl = try await avatarUploader(compressAvatar(avatarImage), session.publicKey) + } catch { + guard currentSession == session else { return false } + throw error + } + guard currentSession == session else { + Logger.info("Dropped a profile save that a session change overtook during its avatar upload", context: "PubkyProfileManager") + return false + } + } + return try await publishProfileEdit(name: name, bio: bio, links: links, tags: tags, newImageUrl: newImageUrl, session: session) } /// Starting the write drops every profile read that started before it, so a refresh that finds the profile missing /// while this edit publishes it can no longer clear the profile or start profile setup. A published profile ends any - /// pending setup. A save that a sign-out or another identity overtakes changes nothing here, as it belongs to a session - /// the user has left. + /// pending setup. The SDK publishes only while the session's identity is still signed in, and a save that a sign-out or + /// another identity overtakes changes nothing here, as it belongs to a session the user has left. Returns whether the + /// edit was saved. private func publishProfileEdit( name: String, bio: String, links: [PubkyProfileLink], tags: [String], - newImageUrl: String? - ) async throws { - let revision = Self.sessionRevision - let editedPublicKey = publicKey + newImageUrl: String?, + session: SignedInSession + ) async throws -> Bool { invalidateProfileLoads() let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: profile?.imageUrl) - try await writeProfile( - name: name, - bio: bio, - imageUrl: resolvedImageUrl, - links: links, - tags: tags - ) - guard revision == Self.sessionRevision, publicKey == editedPublicKey else { + do { + try await writeProfile( + name: name, + bio: bio, + imageUrl: resolvedImageUrl, + links: links, + tags: tags, + expectedIdentity: session.publicKey + ) + } catch PubkyServiceError.identityChanged { + Logger.info("Dropped a profile save for an identity that is no longer signed in", context: "PubkyProfileManager") + return false + } catch { + guard currentSession == session else { return false } + throw error + } + guard currentSession == session else { Logger.info("Dropped a profile save that a session change overtook", context: "PubkyProfileManager") - return + return false } let updatedProfile = PubkyProfile( - publicKey: editedPublicKey ?? "", + publicKey: session.publicKey, name: name, bio: bio, imageUrl: resolvedImageUrl, @@ -831,6 +872,7 @@ class PubkyProfileManager: ObservableObject { ) commitProfile(updatedProfile) setProfileSetupPending(false) + return true } func deleteProfile() async throws { @@ -857,7 +899,8 @@ class PubkyProfileManager: ObservableObject { bio: String, imageUrl: String?, links: [PubkyProfileLink], - tags: [String] = [] + tags: [String] = [], + expectedIdentity: String? = nil ) async throws { let profileData = PubkyProfileData( name: name, @@ -869,7 +912,7 @@ class PubkyProfileManager: ObservableObject { let publish = profilePublisher try await Task.detached { - try await publish(profileData) + try await publish(profileData, expectedIdentity) }.value } @@ -936,8 +979,16 @@ class PubkyProfileManager: ObservableObject { } /// `saveProfile` without its check for a stored session secret. - func saveProfileForTesting(name: String, bio: String, links: [PubkyProfileLink], tags: [String]) async throws { - try await publishProfileEdit(name: name, bio: bio, links: links, tags: tags, newImageUrl: nil) + @discardableResult + func saveProfileForTesting( + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String], + avatarImage: UIImage? = nil + ) async throws -> Bool { + guard let session = currentSession else { return false } + return try await saveProfileEdit(name: name, bio: bio, links: links, tags: tags, avatarImage: avatarImage, session: session) } func clearAuthenticatedStateForTesting() { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 8287a8f8a..ebedb61db 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -278,8 +278,8 @@ enum PubkyService { // MARK: - Profile - static func publishPaykitProfile(_ profile: Paykit.PaykitProfile) async throws { - _ = try await PaykitSdkService.shared.publishPaykitProfile(profile) + static func publishPaykitProfile(_ profile: Paykit.PaykitProfile, expectedIdentity: String? = nil) async throws { + _ = try await PaykitSdkService.shared.publishPaykitProfile(profile, expectedIdentity: expectedIdentity) } static func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String? = nil) async throws -> String { @@ -653,9 +653,15 @@ actor PaykitSdkService { return data } - func publishPaykitProfile(_ profile: Paykit.PaykitProfile) async throws -> Paykit.PaykitProfileRecord { + /// With `expectedIdentity`, it publishes only while that identity is signed in, checked in the same locked operation as + /// the publication, so one that a sign-out or another identity's sign-in overtakes writes nothing and throws + /// `identityChanged`. + func publishPaykitProfile(_ profile: Paykit.PaykitProfile, expectedIdentity: String? = nil) async throws -> Paykit.PaykitProfileRecord { try await withStateRevisionTracking { sdk in - try await sdk.publishPaykitProfile(profile: profile) + if let expectedIdentity { + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + } + return try await sdk.publishPaykitProfile(profile: profile) } } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 0e476cf01..b38021c5d 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -185,6 +185,8 @@ struct EditProfileView: View { // MARK: - Save Profile + /// The save belongs to the Pubky session Save was tapped in. Once that session ends, even while the avatar still uploads + /// or the profile publishes, it stops quietly, with no toast and no navigation. private func saveProfile() async { let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty else { return } @@ -193,18 +195,14 @@ struct EditProfileView: View { defer { isSaving = false } do { - var avatarUri: String? - if let avatarImage { - avatarUri = try await pubkyProfile.uploadAvatar(image: avatarImage) - } - - try await pubkyProfile.saveProfile( + let isSaved = try await pubkyProfile.saveProfile( name: trimmedName, bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, tags: tags, - newImageUrl: avatarUri + avatarImage: avatarImage ) + guard isSaved else { return } app.toast(type: .success, title: t("profile__edit_saved"), accessibilityIdentifier: "ProfileUpdatedToast") navigation.navigateBack() } catch { diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index 91f6ab0a7..ded7eca51 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -43,6 +43,31 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { XCTAssertEqual(sdk.writes, ["save:Alice", "save:Unbound"], "A save for no identity saves for whichever one is signed in") } + func testProfilePublicationForAnIdentityThatIsNoLongerSignedInWritesNothing() async throws { + for signedIn in [identityB, nil] { + let message = signedIn == nil ? "after a sign-out" : "with another identity signed in" + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = signedIn + let service = PaykitSdkService(sdkFactory: { sdk }) + + do { + _ = try await service.publishPaykitProfile(testProfile, expectedIdentity: identityA) + XCTFail("Expected the publication to be refused \(message)") + } catch PubkyServiceError.identityChanged { + } catch { + XCTFail("Expected identityChanged \(message), got \(error)") + } + XCTAssertEqual(sdk.writes, [], "Nothing is written \(message)") + } + + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + _ = try await service.publishPaykitProfile(testProfile, expectedIdentity: bareIdentityA) + _ = try await service.publishPaykitProfile(testProfile) + XCTAssertEqual(sdk.writes, ["profile:Alice", "profile:Alice"], "A publication for the signed-in identity, or for none, publishes") + } + /// The caller checked its session and started the write, which waits for the SDK lock while a sign-out and another /// identity's sign-in land. Only then does the write get the lock, so it must check the identity there. func testWriteThatAnIdentityChangeOvertakesWhileItWaitsForTheSdkLockWritesNothing() async throws { @@ -58,6 +83,12 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { }, { error in (error as? PaykitPaymentRequestError) == .requestUnavailable }), + ("profile publication", { service in + _ = try await service.publishPaykitProfile(testProfile, expectedIdentity: identityA) + }, { error in + if case .identityChanged? = error as? PubkyServiceError { return true } + return false + }), ] for testCase in writes { let sdk = IdentitySwitchingSdk(noPointer: .init()) @@ -116,6 +147,7 @@ private let identityA = "pubky8qd4tbz3hyafi7h7hoqwd5hm7tsaz1n7txcyojmd4kxf9xp7mg private let bareIdentityA = "8qd4tbz3hyafi7h7hoqwd5hm7tsaz1n7txcyojmd4kxf9xp7mgro" private let identityB = "pubkyc1nbnzsfgm1g1rf9um6nh5mdtdhq8mz5i5o4r6g8x4qzi3uqhdmo" private let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" +private let testProfile = PaykitProfile(displayName: "Alice", imageUri: nil, extraJson: nil) /// Holds every call that waits on it while closed. private actor IdentityCheckGate { @@ -238,6 +270,11 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { ) } + override func publishPaykitProfile(profile: PaykitProfile) async throws -> PaykitProfileRecord { + recordWrite("profile:\(profile.displayName ?? "")") + return PaykitProfileRecord(publicKey: identity ?? "", profile: profile, path: "/pub/paykit/profile.json", updatedAt: "2026-10-02T00:00:00Z") + } + private static func record(publicKey: String, label: String?) -> ContactRecord { ContactRecord( publicKey: publicKey, receiverPaths: [PaykitReceiverPath.wallet], label: label, profile: nil, diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 10b1bed66..7c888a842 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1239,7 +1239,7 @@ final class PubkyProfileManagerTests: XCTestCase { await publications.hold() let manager = PubkyProfileManager( remoteProfileResolver: { try await stub.resolve($0) }, - profilePublisher: { await publications.publish($0) } + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) } ) await manager.loadRingIdentityProfiles([bareRingKeyA]) await stub.setProfile(nil, for: ringKeyA) @@ -1289,7 +1289,7 @@ final class PubkyProfileManagerTests: XCTestCase { let publications = ProfilePublications() let manager = PubkyProfileManager( remoteProfileResolver: { try await stub.resolve($0) }, - profilePublisher: { await publications.publish($0) } + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) } ) manager.publicKey = ringKeyA await manager.loadProfile() @@ -1313,7 +1313,7 @@ final class PubkyProfileManagerTests: XCTestCase { await publications.hold() let manager = PubkyProfileManager( remoteProfileResolver: { try await stub.resolve($0) }, - profilePublisher: { await publications.publish($0) } + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) } ) manager.publicKey = ringKeyA await manager.loadProfile() @@ -1338,6 +1338,196 @@ final class PubkyProfileManagerTests: XCTestCase { } } + /// Edit Profile's Save binds the edit to the session it was tapped in. The user signs out and adopts another identity, + /// which has no profile and so starts profile setup, while the new avatar still uploads. + @MainActor + func testProfileEditThatASessionChangeOvertakesDuringItsAvatarUploadStopsQuietly() async { + let avatar = makeAvatarImage() + for uploadSucceeds in [true, false] { + let message = uploadSucceeds ? "after an upload that succeeds" : "after an upload that fails" + await withRestoredProfileDefaults(case: message) { + try await withStoredSessionSecret { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let publications = ProfilePublications() + let uploads = AvatarUploads() + await uploads.hold() + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) }, + avatarUploader: { try await uploads.upload($0, expectedIdentity: $1) } + ) + manager.publicKey = ringKeyA + await manager.loadProfile() + + let save = Task { + try await manager.saveProfile(name: "Alice", bio: "bio", links: [], tags: ["friend"], avatarImage: avatar) + } + await uploads.waitUntilHeld(1) + manager.clearAuthenticatedStateForTesting() + let adopted = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyB) + XCTAssertNil(adopted, message) + XCTAssertTrue(manager.isProfileSetupPending, message) + + await uploads.release(failing: !uploadSucceeds) + let isSaved = try await save.value + + XCTAssertFalse(isSaved, "Nothing is reported saved, so no toast shows and the screen does not navigate, \(message)") + let uploadIdentities = await uploads.expectedIdentities + XCTAssertEqual(uploadIdentities, [ringKeyA], "The upload is for the identity Save was tapped in, \(message)") + let publicationIdentities = await publications.expectedIdentities + XCTAssertEqual(publicationIdentities, [], "Nothing is published once the session changed, \(message)") + XCTAssertEqual(manager.publicKey, ringKeyB, message) + XCTAssertTrue(manager.isProfileSetupPending, "The next identity still sets its profile up, \(message)") + XCTAssertTrue(UserDefaults.standard.bool(forKey: "pubky_profile_setup_pending"), message) + XCTAssertNil(manager.profile, "The earlier identity's profile is not shown for the next one, \(message)") + XCTAssertNil(manager.cachedName, message) + } + } + } + } + + /// The edit's publication waits for the SDK while the user signs out and adopts another identity. The SDK refuses it for + /// the identity that signed out, so the next identity's profile is not overwritten, and the edit reports nothing. + @MainActor + func testProfileEditThatASessionChangeOvertakesWhileItPublishesWritesNothing() async { + let avatar = makeAvatarImage() + for withAvatar in [false, true] { + let message = withAvatar ? "with a new avatar" : "without a new avatar" + await withRestoredProfileDefaults(case: message) { + try await withStoredSessionSecret { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let publications = ProfilePublications() + await publications.signIn(ringKeyA) + await publications.hold() + let uploads = AvatarUploads() + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) }, + avatarUploader: { try await uploads.upload($0, expectedIdentity: $1) } + ) + manager.publicKey = ringKeyA + await manager.loadProfile() + + let save = Task { + try await manager.saveProfile(name: "Alice", bio: "bio", links: [], tags: ["friend"], avatarImage: withAvatar ? avatar : nil) + } + await publications.waitUntilHeld(1) + manager.clearAuthenticatedStateForTesting() + _ = try await manager.completeRingAdoptionForTesting(publicKey: ringKeyB) + await publications.signIn(ringKeyB) + + await publications.release() + let isSaved = try await save.value + + XCTAssertFalse(isSaved, "Nothing is reported saved, so no toast shows and the screen does not navigate, \(message)") + let published = await publications.published + XCTAssertEqual(published, [], "The next identity's profile is not overwritten, \(message)") + let publicationIdentities = await publications.expectedIdentities + XCTAssertEqual(publicationIdentities, [ringKeyA], "The publication is for the identity Save was tapped in, \(message)") + let uploadIdentities = await uploads.expectedIdentities + XCTAssertEqual(uploadIdentities, withAvatar ? [ringKeyA] : [], message) + XCTAssertEqual(manager.publicKey, ringKeyB, message) + XCTAssertTrue(manager.isProfileSetupPending, "The next identity still sets its profile up, \(message)") + XCTAssertNil(manager.profile, message) + XCTAssertNil(manager.cachedName, message) + } + } + } + } + + /// The SDK refuses the publication because another identity is signed in while the session still looks current, as it + /// can for a sign-in the session has not caught up with. The edit treats the refusal as stale: it reports nothing and + /// leaves the profile and a pending setup as they were. + @MainActor + func testProfileEditTheSdkRefusesForAnotherIdentityIsDroppedQuietly() async throws { + try await withRestoredProfileDefaults { + try await withStoredSessionSecret { + UserDefaults.standard.set(true, forKey: "pubky_profile_setup_pending") + let loaded = makeProfile(publicKey: ringKeyA, name: "Alice") + let stub = RemoteProfileStub(profiles: [ringKeyA: loaded]) + let publications = ProfilePublications() + await publications.signIn(ringKeyB) + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) }, + avatarUploader: { _, _ in uploadedAvatarUri } + ) + manager.publicKey = ringKeyA + await manager.loadProfile() + + let isSaved = try await manager.saveProfile(name: "Alice", bio: "new bio", links: [], tags: ["friend"]) + + XCTAssertFalse(isSaved, "Nothing is reported saved, so no toast shows and the screen does not navigate") + let published = await publications.published + XCTAssertEqual(published, []) + XCTAssertEqual(manager.profile?.publicKey, ringKeyA, "The profile is left as it was") + XCTAssertEqual(manager.profile?.bio, loaded.bio) + XCTAssertEqual(manager.profile?.tags, loaded.tags) + XCTAssertTrue(manager.isProfileSetupPending, "So is the pending setup") + XCTAssertTrue(UserDefaults.standard.bool(forKey: "pubky_profile_setup_pending")) + } + } + } + + @MainActor + func testProfileEditWithAnAvatarPublishesTheUploadedAvatarForItsIdentity() async throws { + try await withRestoredProfileDefaults { + try await withStoredSessionSecret { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let publications = ProfilePublications() + await publications.signIn(ringKeyA) + let uploads = AvatarUploads() + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) }, + avatarUploader: { try await uploads.upload($0, expectedIdentity: $1) } + ) + manager.publicKey = ringKeyA + await manager.loadProfile() + + let isSaved = try await manager.saveProfile( + name: "Alice", + bio: "new bio", + links: [], + tags: ["friend"], + avatarImage: makeAvatarImage() + ) + + XCTAssertTrue(isSaved) + let uploadIdentities = await uploads.expectedIdentities + XCTAssertEqual(uploadIdentities, [ringKeyA]) + let publicationIdentities = await publications.expectedIdentities + XCTAssertEqual(publicationIdentities, [ringKeyA]) + let published = await publications.published + XCTAssertEqual(published.map(\.image), [uploadedAvatarUri]) + XCTAssertEqual(manager.profile?.imageUrl, uploadedAvatarUri) + XCTAssertEqual(manager.profile?.bio, "new bio") + XCTAssertEqual(manager.profile?.tags, ["friend"]) + } + } + } + + /// Save on a profile screen while nothing is signed in, such as during a sign-out, does nothing and reports nothing. + @MainActor + func testProfileEditWithoutASignedInSessionSavesNothing() async throws { + try await withRestoredProfileDefaults { + let publications = ProfilePublications() + let uploads = AvatarUploads() + let manager = PubkyProfileManager( + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) }, + avatarUploader: { try await uploads.upload($0, expectedIdentity: $1) } + ) + + let isSaved = try await manager.saveProfile(name: "Alice", bio: "bio", links: [], tags: [], avatarImage: makeAvatarImage()) + + XCTAssertFalse(isSaved) + let uploadIdentities = await uploads.expectedIdentities + XCTAssertEqual(uploadIdentities, []) + let publicationIdentities = await publications.expectedIdentities + XCTAssertEqual(publicationIdentities, []) + } + } + // MARK: - Avatar uploads /// Edit Contact uploads a new avatar for the identity Save was tapped in. The upload hands that identity to the SDK, @@ -2753,23 +2943,65 @@ private actor RemoteProfileStub { private let uploadedAvatarUri = "pubky://uploaded/avatar.jpg" -/// Stands in for the SDK's avatar upload: records the identity each upload is for. +private struct AvatarUploadError: Error {} + +/// Stands in for the SDK's avatar upload: records the identity each upload is for, and can hold uploads until released, +/// then let them succeed or fail. A wait for held uploads that outlasts the deadline fails the test instead of hanging. private actor AvatarUploads { private(set) var expectedIdentities: [String?] = [] + private var isHolding = false + private var failsReleasedUploads = false + private var held: [CheckedContinuation] = [] + + func hold() { + isHolding = true + } + + func release(failing: Bool = false) { + isHolding = false + failsReleasedUploads = failing + held.forEach { $0.resume() } + held.removeAll() + } - func upload(_: Data, expectedIdentity: String?) -> String { + func upload(_: Data, expectedIdentity: String?) async throws -> String { expectedIdentities.append(expectedIdentity) + if isHolding { + await withCheckedContinuation { held.append($0) } + if failsReleasedUploads { + throw AvatarUploadError() + } + } return uploadedAvatarUri } + + func waitUntilHeld(_ count: Int, file: StaticString = #filePath, line: UInt = #line) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while held.count < count { + guard ContinuousClock.now < deadline else { + XCTFail("Timed out waiting for \(count) held uploads; saw \(held.count)", file: file, line: line) + return + } + try? await Task.sleep(for: .milliseconds(5)) + } + } } -/// Stands in for publishing the signed-in profile: records each publication and can hold them until released. A wait for -/// held publications that outlasts the deadline fails the test instead of hanging the suite. +/// Stands in for publishing the signed-in profile: records the identity each publication is for and each one published, +/// and can hold them until released. Once told which identity is signed in, it refuses, like the SDK, a publication for +/// another identity, checked together with the write. A wait for held publications that outlasts the deadline fails the +/// test instead of hanging the suite. private actor ProfilePublications { private(set) var published: [PubkyProfileData] = [] + private(set) var expectedIdentities: [String?] = [] + private var signedInIdentity: String? private var isHolding = false private var held: [CheckedContinuation] = [] + func signIn(_ identity: String) { + signedInIdentity = identity + } + func hold() { isHolding = true } @@ -2780,10 +3012,14 @@ private actor ProfilePublications { held.removeAll() } - func publish(_ profile: PubkyProfileData) async { + func publish(_ profile: PubkyProfileData, expectedIdentity: String?) async throws { + expectedIdentities.append(expectedIdentity) if isHolding { await withCheckedContinuation { held.append($0) } } + if let signedInIdentity, let expectedIdentity, expectedIdentity != signedInIdentity { + throw PubkyServiceError.identityChanged + } published.append(profile) } From 6d295182109e7495b89e861b7d8e0eb237b4b4f2 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 2 Oct 2026 22:31:53 +0200 Subject: [PATCH 78/79] fix: report a profile avatar upload error as a profile error Binding profile and contact avatar uploads to an identity routed them through the subscription icon upload's check, which throws the payment request error requestUnavailable both for another identity and for no live session. Edit Profile then showed "The payment request is no longer available." when an upload failed for the signed-in identity. Profile and contact avatars now use their own upload, which checks only the identity under the Paykit lock: another identity throws identityChanged, which Edit Profile and Edit Contact drop quietly, and any other failure, such as no live session, is the SDK's own error, as before. The payment request upload is unchanged. --- Bitkit/Managers/ContactsManager.swift | 6 +- Bitkit/Managers/PubkyProfileManager.swift | 5 +- Bitkit/Services/PubkyService.swift | 18 ++++- BitkitTests/ContactsManagerTests.swift | 9 +++ BitkitTests/PaykitSdkIdentityCheckTests.swift | 76 ++++++++++++++++++- BitkitTests/PubkyProfileManagerTests.swift | 66 ++++++++++++++++ 6 files changed, 175 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index a3d6739b4..6193484a7 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -700,7 +700,8 @@ class ContactsManager: ObservableObject { /// result or error is dropped, and it saves nothing, writes no local override and reports no error. The lookup or /// upload it waits for can finish after a sign-out, and the next identity may have saved a contact with the same key. /// `expectedIdentity` is the pubky signed in when Save was tapped. The save is refused unless it is still signed in - /// when the save runs, and that refusal is dropped just as quietly. + /// when the save runs, and that refusal, or `makeEdit` throwing `identityChanged` for an avatar upload refused the same + /// way, is dropped just as quietly. func saveContactEdit( publicKey: String, expectedIdentity: String, @@ -713,6 +714,9 @@ class ContactsManager: ObservableObject { let edit: ContactEdit do { edit = try await makeEdit() + } catch PubkyServiceError.identityChanged { + Logger.info("Dropped a contact edit whose avatar upload was for an identity that is no longer signed in", context: "ContactsManager") + return nil } catch { guard isCurrent() else { return nil } throw error diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 38b694e92..48c2a2ffe 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -354,7 +354,7 @@ class PubkyProfileManager: ObservableObject { } /// Upload an avatar image to the user's homeserver blob storage. Returns the `pubky://` URI. With `expectedIdentity`, - /// the upload writes nothing and fails unless that identity is still signed in, with a live session, when it runs. + /// the upload writes nothing and throws `identityChanged` unless that identity is still signed in when it runs. func uploadAvatar(image: UIImage, expectedIdentity: String? = nil) async throws -> String { _ = try activeSessionSecret() let imageData = try compressAvatar(image) @@ -812,6 +812,9 @@ class PubkyProfileManager: ObservableObject { if let avatarImage { do { newImageUrl = try await avatarUploader(compressAvatar(avatarImage), session.publicKey) + } catch PubkyServiceError.identityChanged { + Logger.info("Dropped a profile save whose avatar upload was for another identity", context: "PubkyProfileManager") + return false } catch { guard currentSession == session else { return false } throw error diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index ebedb61db..0d2f4cf1e 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -283,7 +283,7 @@ enum PubkyService { } static func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String? = nil) async throws -> String { - try await PaykitSdkService.shared.uploadProfileAvatar(bytes: bytes, contentType: contentType, expectedIdentity: expectedIdentity) + try await PaykitSdkService.shared.uploadAvatar(bytes: bytes, contentType: contentType, expectedIdentity: expectedIdentity) } static func deletePaykitProfile() async throws { @@ -665,6 +665,8 @@ actor PaykitSdkService { } } + /// The upload for payment requests, such as a subscription icon. With `expectedIdentity`, it throws + /// `requestUnavailable` unless that identity is signed in with a live session. func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String? = nil) async throws -> String { let record = try await withStateRevisionTracking { sdk in if let expectedIdentity { @@ -678,6 +680,20 @@ actor PaykitSdkService { return record.uri } + /// The upload for profile and contact avatars. With `expectedIdentity`, it uploads only while that identity is signed + /// in, checked in the same locked operation as the upload, so one that a sign-out or another identity's sign-in + /// overtakes writes nothing and throws `identityChanged`. Any other failure, such as having no live session, is the + /// SDK's own error. + func uploadAvatar(bytes: Data, contentType: String, expectedIdentity: String?) async throws -> String { + let record = try await withStateRevisionTracking { sdk in + if let expectedIdentity { + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + } + return try await sdk.uploadProfileAvatar(bytes: bytes, contentType: contentType) + } + return record.uri + } + func deletePaykitProfile() async throws { try await withStateRevisionTracking { sdk in try await sdk.deletePaykitProfile() diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 117a9f9d8..ba576f81a 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1478,7 +1478,16 @@ final class ContactsManagerTests: XCTestCase { } catch { XCTFail("A refused edit must not report an error, so no toast shows: \(error)") } + var uploadRefusedProfile: Bitkit.PubkyProfile? + do { + uploadRefusedProfile = try await manager.saveContactEdit( + publicKey: contactProfileKey, expectedIdentity: "owner-a", isSessionCurrent: { true } + ) { throw PubkyServiceError.identityChanged } + } catch { + XCTFail("An edit whose avatar upload was refused must not report an error, so no toast shows: \(error)") + } + XCTAssertNil(uploadRefusedProfile, "Nor is an edit whose avatar upload the SDK refused") XCTAssertNil(savedProfile, "Nothing is reported saved, so Edit Contact shows no toast and does not navigate") let refused = await store.refusedIdentities XCTAssertEqual(refused, ["owner-a", "owner-a"]) diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index ded7eca51..aceccf1b2 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -68,6 +68,60 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { XCTAssertEqual(sdk.writes, ["profile:Alice", "profile:Alice"], "A publication for the signed-in identity, or for none, publishes") } + func testProfileAvatarUploadForAnIdentityThatIsNoLongerSignedInThrowsIdentityChanged() async throws { + for signedIn in [identityB, nil] { + let message = signedIn == nil ? "after a sign-out" : "with another identity signed in" + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = signedIn + let service = PaykitSdkService(sdkFactory: { sdk }) + + do { + _ = try await service.uploadAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: identityA) + XCTFail("Expected the upload to be refused \(message)") + } catch PubkyServiceError.identityChanged { + } catch { + XCTFail("Expected identityChanged \(message), got \(error)") + } + XCTAssertEqual(sdk.writes, [], "Nothing is written \(message)") + } + + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + let uri = try await service.uploadAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: bareIdentityA) + XCTAssertEqual(uri, "pubky://avatar") + XCTAssertEqual(sdk.writes, ["upload"]) + } + + /// Without a live session, a profile or contact avatar upload for the signed-in identity fails with the SDK's own + /// error, as it did before uploads were bound to an identity, so Edit Profile does not show a payment request error. + /// The payment request upload still reports `requestUnavailable`. + func testProfileAvatarUploadWithoutALiveSessionReportsTheSdksOwnError() async throws { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + sdk.hasLiveSession = false + let service = PaykitSdkService(sdkFactory: { sdk }) + + do { + _ = try await service.uploadAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: identityA) + XCTFail("Expected the upload to fail without a live session") + } catch let error as PaykitError { + guard case .Identity = error else { return XCTFail("Expected the SDK's identity error, got \(error)") } + XCTAssertEqual(error.localizedDescription, noLiveSessionError.localizedDescription) + XCTAssertNotEqual(error.localizedDescription, PaykitPaymentRequestError.requestUnavailable.localizedDescription) + } catch { + XCTFail("Expected the SDK's own error, got \(error)") + } + + do { + _ = try await service.uploadProfileAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: identityA) + XCTFail("Expected the payment request upload to be refused") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable, "The payment request upload is unchanged") + } + XCTAssertEqual(sdk.writes, []) + } + /// The caller checked its session and started the write, which waits for the SDK lock while a sign-out and another /// identity's sign-in land. Only then does the write get the lock, so it must check the identity there. func testWriteThatAnIdentityChangeOvertakesWhileItWaitsForTheSdkLockWritesNothing() async throws { @@ -78,7 +132,13 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { if case .identityChanged? = error as? PubkyServiceError { return true } return false }), - ("avatar upload", { service in + ("profile avatar upload", { service in + _ = try await service.uploadAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: identityA) + }, { error in + if case .identityChanged? = error as? PubkyServiceError { return true } + return false + }), + ("payment request upload", { service in _ = try await service.uploadProfileAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: identityA) }, { error in (error as? PaykitPaymentRequestError) == .requestUnavailable @@ -148,6 +208,11 @@ private let bareIdentityA = "8qd4tbz3hyafi7h7hoqwd5hm7tsaz1n7txcyojmd4kxf9xp7mgr private let identityB = "pubkyc1nbnzsfgm1g1rf9um6nh5mdtdhq8mz5i5o4r6g8x4qzi3uqhdmo" private let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private let testProfile = PaykitProfile(displayName: "Alice", imageUri: nil, extraJson: nil) +/// What the SDK reports for a blob upload without a live Pubky session. +private let noLiveSessionError = PaykitError.Identity( + code: "identity_error", + context: "cannot publish Paykit blob without an active Pubky session" +) /// Holds every call that waits on it while closed. private actor IdentityCheckGate { @@ -198,6 +263,7 @@ private actor IdentityCheckCallLog { private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { private let lock = NSLock() private var signedInIdentity: String? + private var liveSession = true private var recordedWrites: [String] = [] private var recordedIdentitiesAtWrites: [String?] = [] private var recordedContactReads = 0 @@ -209,6 +275,11 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { set { lock.withLock { signedInIdentity = newValue } } } + var hasLiveSession: Bool { + get { lock.withLock { liveSession } } + set { lock.withLock { liveSession = newValue } } + } + var writes: [String] { lock.withLock { recordedWrites } } @@ -230,7 +301,7 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { } override func identityStatus() async throws -> IdentityStatus? { - IdentityStatus(publicKey: identity, liveSessionAvailable: identity != nil) + IdentityStatus(publicKey: identity, liveSessionAvailable: identity != nil && hasLiveSession) } override func backupStateRevision() async throws -> String { @@ -263,6 +334,7 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { } override func uploadProfileAvatar(bytes: Data, contentType _: String) async throws -> PaykitBlobRecord { + guard identity != nil, hasLiveSession else { throw noLiveSessionError } recordWrite("upload") return PaykitBlobRecord( publicKey: identity ?? "", path: "/pub/paykit/blobs/avatar.jpg", uri: "pubky://avatar", diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 7c888a842..3bc7fda14 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1469,6 +1469,66 @@ final class PubkyProfileManagerTests: XCTestCase { } } + /// An avatar upload refused for another identity while the session still looks current is stale like a refused + /// publication: the edit reports nothing and publishes nothing. + @MainActor + func testProfileEditWhoseAvatarUploadIsRefusedForAnotherIdentityIsDroppedQuietly() async throws { + try await withRestoredProfileDefaults { + try await withStoredSessionSecret { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let publications = ProfilePublications() + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) }, + avatarUploader: { _, _ in throw PubkyServiceError.identityChanged } + ) + manager.publicKey = ringKeyA + await manager.loadProfile() + + let isSaved = try await manager.saveProfile( + name: "Alice", + bio: "new bio", + links: [], + tags: ["friend"], + avatarImage: makeAvatarImage() + ) + + XCTAssertFalse(isSaved, "Nothing is reported saved, so no toast shows and the screen does not navigate") + let publicationIdentities = await publications.expectedIdentities + XCTAssertEqual(publicationIdentities, [], "Nothing is published") + XCTAssertEqual(manager.profile?.bio, "bio", "The profile is left as it was") + } + } + } + + /// An avatar upload that fails while the session is current, such as without a live session, reports the upload's own + /// error, which Edit Profile shows, and publishes nothing. + @MainActor + func testProfileEditReportsTheAvatarUploadsOwnError() async throws { + try await withRestoredProfileDefaults { + try await withStoredSessionSecret { + let stub = RemoteProfileStub(profiles: [ringKeyA: makeProfile(publicKey: ringKeyA, name: "Alice")]) + let publications = ProfilePublications() + let manager = PubkyProfileManager( + remoteProfileResolver: { try await stub.resolve($0) }, + profilePublisher: { try await publications.publish($0, expectedIdentity: $1) }, + avatarUploader: { _, _ in throw NoLiveSessionUploadError() } + ) + manager.publicKey = ringKeyA + await manager.loadProfile() + + do { + _ = try await manager.saveProfile(name: "Alice", bio: "new bio", links: [], tags: [], avatarImage: makeAvatarImage()) + XCTFail("Expected the upload's error") + } catch { + XCTAssertEqual(error.localizedDescription, NoLiveSessionUploadError().localizedDescription) + } + let publicationIdentities = await publications.expectedIdentities + XCTAssertEqual(publicationIdentities, []) + } + } + } + @MainActor func testProfileEditWithAnAvatarPublishesTheUploadedAvatarForItsIdentity() async throws { try await withRestoredProfileDefaults { @@ -2945,6 +3005,12 @@ private let uploadedAvatarUri = "pubky://uploaded/avatar.jpg" private struct AvatarUploadError: Error {} +private struct NoLiveSessionUploadError: LocalizedError { + var errorDescription: String? { + "cannot publish Paykit blob without an active Pubky session" + } +} + /// Stands in for the SDK's avatar upload: records the identity each upload is for, and can hold uploads until released, /// then let them succeed or fail. A wait for held uploads that outlasts the deadline fails the test instead of hanging. private actor AvatarUploads { From 3ced8b197f94e5d6d96f6e7c25fc92dadf80b83e Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Sat, 3 Oct 2026 11:47:50 +0200 Subject: [PATCH 79/79] fix: let the latest contact payments change win over an older one General Settings' first automatic enable runs in its own task, so a disable from a General Settings opened again could start while that enable still prepared endpoints, and the enable then published the marker and endpoints after the disable had removed them. Each change now takes the next change number before it suspends and stops quietly once a newer one starts, as it already did for a session change. Its endpoint publications and removals check this under their locks, and a public removal now also removes the marker under the endpoint lock, so an older change never writes or removes endpoints after a newer one. --- Bitkit/Services/ContactPaymentsService.swift | 137 ++++---- .../PrivatePaykitService+Contacts.swift | 17 +- Bitkit/Services/PublicPaykitService.swift | 45 ++- Bitkit/Views/Profile/PayContactsView.swift | 6 +- .../Views/Settings/GeneralSettingsView.swift | 2 +- BitkitTests/ContactPaymentsServiceTests.swift | 315 +++++++++++++++++- 6 files changed, 426 insertions(+), 96 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 1ecbcdcbd..312c41d6e 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -3,36 +3,39 @@ import Foundation enum ContactPaymentsService { static let confirmedPreferenceKey = "hasConfirmedPublicPaykitEndpoints" - /// True while the Pubky session a contact payments change started for is still the current one. - typealias SessionCheck = @MainActor () -> Bool + /// True while a contact payments change is still current: no newer change has started, and the Pubky session it + /// started for is still the current one. Once false, it never turns true again. + typealias ChangeCheck = @MainActor () -> Bool + + @MainActor private static var latestChange = 0 struct Operations { - let syncPublicEndpoints: (_ publish: Bool, _ isSessionCurrent: @escaping SessionCheck) async throws -> Void + let syncPublicEndpoints: (_ publish: Bool, _ isChangeCurrent: @escaping ChangeCheck) async throws -> Void let preparePrivateEndpoints: ( _ contactPublicKeys: [String], _ requireImmediatePublication: Bool, - _ isSessionCurrent: @escaping SessionCheck + _ isChangeCurrent: @escaping ChangeCheck ) async -> Error? - let removePrivateEndpoints: () async throws -> Void + let removePrivateEndpoints: (_ isChangeCurrent: @escaping ChangeCheck) async throws -> Void let setPublicCleanupPending: (_ isPending: Bool) -> Void let setPrivateCleanupPending: (_ isPending: Bool) -> Void @MainActor static func live(wallet: WalletViewModel) -> Operations { Operations( - syncPublicEndpoints: { publish, isSessionCurrent in - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish, isSessionCurrent: isSessionCurrent) + syncPublicEndpoints: { publish, isChangeCurrent in + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish, isSessionCurrent: isChangeCurrent) }, - preparePrivateEndpoints: { contactPublicKeys, requireImmediatePublication, isSessionCurrent in + preparePrivateEndpoints: { contactPublicKeys, requireImmediatePublication, isChangeCurrent in await PrivatePaykitService.shared.prepareSavedContacts( contactPublicKeys, wallet: wallet, requireImmediatePublication: requireImmediatePublication, - isSessionCurrent: isSessionCurrent + isSessionCurrent: isChangeCurrent ) }, - removePrivateEndpoints: { - try await PrivatePaykitService.shared.removePublishedEndpoints() + removePrivateEndpoints: { isChangeCurrent in + try await PrivatePaykitService.shared.removePublishedEndpoints(isSessionCurrent: isChangeCurrent) }, setPublicCleanupPending: PublicPaykitService.setCleanupPending, setPrivateCleanupPending: PrivatePaykitService.setContactSharingCleanupPending @@ -60,10 +63,12 @@ enum ContactPaymentsService { } /// Turns contact payments on or off for the signed-in Pubky session. Private endpoints are prepared for the saved - /// contacts, so it first waits for the first contacts load. Once that session ends or changes, the change stops and - /// returns quietly: it writes no preference, publishing flag or cleared cleanup mark, and its endpoint publications - /// check the session under the locks that sign-out's endpoint removal also takes, so they never write endpoints back - /// after that removal. Returns true once the change was applied, and false when it stopped for a session change. + /// contacts, so it first waits for the first contacts load. The latest change wins: a change from General Settings or + /// Pay Contacts can outlive its screen, so a newer one can start while it still runs. Once a newer change starts, or + /// that session ends or changes, the change stops and returns quietly: it writes no preference, publishing flag or + /// cleanup mark and restores nothing, and its endpoint publications and removals check that it is still current under + /// the locks that the newer change's endpoint writes and sign-out's endpoint removal also take, so they never land + /// after those. Returns true once the change was applied, and false when it stopped. @discardableResult @MainActor static func setEnabled( @@ -73,18 +78,20 @@ enum ContactPaymentsService { operations: Operations, defaults: UserDefaults = .standard ) async throws -> Bool { + latestChange += 1 + let change = latestChange guard let session = pubkyProfile.currentSession else { return false } - let isSessionCurrent: SessionCheck = { pubkyProfile.currentSession == session } + let isChangeCurrent: ChangeCheck = { Self.latestChange == change && pubkyProfile.currentSession == session } let canUsePrivatePayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile if canUsePrivatePayments { do { try await contactsManager.loadContactsIfNeeded(for: session.publicKey) } catch { - guard isSessionCurrent() else { return false } + guard isChangeCurrent() else { return false } throw error } } - guard isSessionCurrent() else { return false } + guard isChangeCurrent() else { return false } do { try await setEnabled( @@ -93,20 +100,20 @@ enum ContactPaymentsService { canUsePrivatePayments: canUsePrivatePayments, operations: operations, defaults: defaults, - isSessionCurrent: isSessionCurrent + isChangeCurrent: isChangeCurrent ) } catch { - guard isSessionCurrent() else { return false } + guard isChangeCurrent() else { return false } throw error } - // The change above stops quietly once the session changes, and a changed session never compares current again, - // so this also reports that stop. - return isSessionCurrent() + // The change above stops quietly once it is no longer current, and it never becomes current again, so this also + // reports that stop. + return isChangeCurrent() } - /// Once `isSessionCurrent` is false, the preference, flags and cleanup marks belong to the sign-out or other session + /// Once `isChangeCurrent` is false, the preference, flags and cleanup marks belong to the newer change or the session /// change that started, so it is checked right before each write here, with nothing suspending in between, and the - /// endpoint publications check it again under their locks. + /// endpoint publications and removals check it again under their locks. @MainActor static func setEnabled( _ enabled: Bool, @@ -114,7 +121,7 @@ enum ContactPaymentsService { canUsePrivatePayments: Bool, operations: Operations, defaults: UserDefaults = .standard, - isSessionCurrent: @escaping SessionCheck = { true } + isChangeCurrent: @escaping ChangeCheck = { true } ) async throws { enableAllPaymentOptions(defaults: defaults) @@ -133,10 +140,10 @@ enum ContactPaymentsService { canUsePrivatePayments: canUsePrivatePayments, operations: operations, defaults: defaults, - isSessionCurrent: isSessionCurrent + isChangeCurrent: isChangeCurrent ) } else { - try await disable(operations: operations, defaults: defaults, isSessionCurrent: isSessionCurrent) + try await disable(operations: operations, defaults: defaults, isChangeCurrent: isChangeCurrent) } } catch { await restore( @@ -145,7 +152,7 @@ enum ContactPaymentsService { canUsePrivatePayments: canUsePrivatePayments, operations: operations, defaults: defaults, - isSessionCurrent: isSessionCurrent + isChangeCurrent: isChangeCurrent ) throw error } @@ -157,9 +164,9 @@ enum ContactPaymentsService { canUsePrivatePayments: Bool, operations: Operations, defaults: UserDefaults, - isSessionCurrent: @escaping SessionCheck + isChangeCurrent: @escaping ChangeCheck ) async throws { - guard isSessionCurrent() else { return } + guard isChangeCurrent() else { return } if !canUsePrivatePayments, defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey) { operations.setPrivateCleanupPending(true) } @@ -171,15 +178,15 @@ enum ContactPaymentsService { let error = await operations.preparePrivateEndpoints( contactPublicKeys, true, - isSessionCurrent + isChangeCurrent ) { throw error } - try await operations.syncPublicEndpoints(true, isSessionCurrent) + try await operations.syncPublicEndpoints(true, isChangeCurrent) - guard isSessionCurrent() else { return } + guard isChangeCurrent() else { return } operations.setPublicCleanupPending(false) if canUsePrivatePayments { operations.setPrivateCleanupPending(false) @@ -187,31 +194,35 @@ enum ContactPaymentsService { } @MainActor - private static func disable(operations: Operations, defaults: UserDefaults, isSessionCurrent: @escaping SessionCheck) async throws { + private static func disable(operations: Operations, defaults: UserDefaults, isChangeCurrent: @escaping ChangeCheck) async throws { do { - try await operations.removePrivateEndpoints() - if isSessionCurrent() { + try await operations.removePrivateEndpoints(isChangeCurrent) + if isChangeCurrent() { operations.setPrivateCleanupPending(false) } } catch { - operations.setPrivateCleanupPending(true) - Logger.warn( - "Deferred private Paykit endpoint cleanup after disable failed: \(error)", - context: "ContactPaymentsService" - ) + if isChangeCurrent() { + operations.setPrivateCleanupPending(true) + Logger.warn( + "Deferred private Paykit endpoint cleanup after disable failed: \(error)", + context: "ContactPaymentsService" + ) + } } do { - try await operations.syncPublicEndpoints(false, isSessionCurrent) - if isSessionCurrent() { + try await operations.syncPublicEndpoints(false, isChangeCurrent) + if isChangeCurrent() { operations.setPublicCleanupPending(false) } } catch { - operations.setPublicCleanupPending(true) + if isChangeCurrent() { + operations.setPublicCleanupPending(true) + } throw error } - guard isSessionCurrent() else { return } + guard isChangeCurrent() else { return } defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(true, forKey: confirmedPreferenceKey) @@ -224,44 +235,50 @@ enum ContactPaymentsService { canUsePrivatePayments: Bool, operations: Operations, defaults: UserDefaults, - isSessionCurrent: @escaping SessionCheck + isChangeCurrent: @escaping ChangeCheck ) async { - guard isSessionCurrent() else { return } + guard isChangeCurrent() else { return } let restoresPrivateEndpoints = state.sharesPrivateEndpoints && canUsePrivatePayments defaults.set(state.sharesPublicEndpoints, forKey: PublicPaykitService.publishingEnabledKey) defaults.set(restoresPrivateEndpoints, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(state.hasConfirmedPreference, forKey: confirmedPreferenceKey) do { - try await operations.syncPublicEndpoints(state.sharesPublicEndpoints, isSessionCurrent) - if isSessionCurrent() { + try await operations.syncPublicEndpoints(state.sharesPublicEndpoints, isChangeCurrent) + if isChangeCurrent() { operations.setPublicCleanupPending(state.publicCleanupPending) } } catch { - operations.setPublicCleanupPending(true) - Logger.warn("Failed to restore public contact payments: \(error)", context: "ContactPaymentsService") + if isChangeCurrent() { + operations.setPublicCleanupPending(true) + Logger.warn("Failed to restore public contact payments: \(error)", context: "ContactPaymentsService") + } } if restoresPrivateEndpoints { if let error = await operations.preparePrivateEndpoints( contactPublicKeys, true, - isSessionCurrent + isChangeCurrent ) { - operations.setPrivateCleanupPending(true) - Logger.warn("Failed to restore private contact payments: \(error)", context: "ContactPaymentsService") - } else if isSessionCurrent() { + if isChangeCurrent() { + operations.setPrivateCleanupPending(true) + Logger.warn("Failed to restore private contact payments: \(error)", context: "ContactPaymentsService") + } + } else if isChangeCurrent() { operations.setPrivateCleanupPending(state.privateCleanupPending) } } else { do { - try await operations.removePrivateEndpoints() - if isSessionCurrent() { + try await operations.removePrivateEndpoints(isChangeCurrent) + if isChangeCurrent() { operations.setPrivateCleanupPending(state.privateCleanupPending) } } catch { - operations.setPrivateCleanupPending(true) - Logger.warn("Failed to clean up private contact payments: \(error)", context: "ContactPaymentsService") + if isChangeCurrent() { + operations.setPrivateCleanupPending(true) + Logger.warn("Failed to clean up private contact payments: \(error)", context: "ContactPaymentsService") + } } } } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 0b1554a1c..053f5d6b2 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -159,18 +159,23 @@ extension PrivatePaykitService { ) } - func removePublishedEndpoints() async throws { + func removePublishedEndpoints(isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws { let publicKeys = Set(knownSavedContactKeys) .union(state.contacts.keys) .union(Self.pendingDeletedContactCleanupKeys()) - try await removePublishedEndpoints(for: Array(publicKeys)) + try await removePublishedEndpoints(for: Array(publicKeys), isSessionCurrent: isSessionCurrent) } - func removePublishedEndpoints(for publicKeys: [String]) async throws { + /// A newer contact payments change starts before it publishes endpoints under the publication lock, so checking + /// `isSessionCurrent` once the lock is held stops an older change's removal from clearing what that change published. + func removePublishedEndpoints(for publicKeys: [String], isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws { let publicKeys = normalizedSavedContactKeys(publicKeys) guard !publicKeys.isEmpty else { return } try await withPublicationLock { + if let isSessionCurrent, await !isSessionCurrent() { + throw PubkyServiceError.sessionNotActive + } try await removePublishedEndpointsLocked(for: publicKeys) } } @@ -369,9 +374,9 @@ extension PrivatePaykitService { ) } - /// Sign-out changes the current Pubky session before it removes private endpoints under the publication lock, so - /// checking `isSessionCurrent` once the lock is held stops a publish that would otherwise write them back after that - /// removal. + /// Sign-out changes the current Pubky session, and a newer contact payments change starts, before either removes + /// private endpoints under the publication lock, so checking `isSessionCurrent` once the lock is held stops a publish + /// that would otherwise write them back after that removal. func syncLocalEndpointPublication( for publicKeys: [String], reason: String, diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 7605e90f3..7a86859a9 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -321,19 +321,23 @@ enum PublicPaykitService { isSessionCurrent: (@MainActor () -> Bool)? = nil ) async throws { guard publish else { - var firstError: Error? - do { - try await removePublishedEndpoints() - } catch { - firstError = firstError ?? error - } - do { - try await syncLocalReceiverMarker(publicSharingEnabled: false) - } catch { - firstError = firstError ?? error - } - if let firstError { - throw firstError + // The marker removal shares the lock with the endpoint removal, so a newer publish that checks under the lock + // lands wholly before or after both. + try await withEndpointLock(unlessSessionEnded: isSessionCurrent) { + var firstError: Error? + do { + try await applyPublishedEndpointsLocked([]) + } catch { + firstError = error + } + do { + try await syncLocalReceiverMarker(publicSharingEnabled: false) + } catch { + firstError = firstError ?? error + } + if let firstError { + throw firstError + } } return } @@ -451,7 +455,8 @@ enum PublicPaykitService { /// Runs `operation` under the lock that publishing and removing public endpoints share. Sign-out changes the current /// Pubky session before it removes endpoints under this lock, so checking `isSessionCurrent` once the lock is held - /// stops a publish that would otherwise write endpoints back after that removal. + /// stops a publish that would otherwise write endpoints back after that removal. A newer contact payments change + /// likewise starts before it writes endpoints under this lock, so an older change's check stops its publish or removal. static func withEndpointLock( unlessSessionEnded isSessionCurrent: (@MainActor () -> Bool)? = nil, _ operation: () async throws -> T @@ -471,10 +476,14 @@ enum PublicPaykitService { ) async throws { try await withEndpointLock(unlessSessionEnded: isSessionCurrent) { try await beforeApplying() - let report = try await PaykitSdkService.shared.syncPublicEndpoints(desiredEndpoints) - guard report.failed.isEmpty else { - throw PublicPaykitError.publicationFailed - } + try await applyPublishedEndpointsLocked(desiredEndpoints) + } + } + + private static func applyPublishedEndpointsLocked(_ desiredEndpoints: [Endpoint]) async throws { + let report = try await PaykitSdkService.shared.syncPublicEndpoints(desiredEndpoints) + guard report.failed.isEmpty else { + throw PublicPaykitError.publicationFailed } } diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index ece5e18e7..161c9e2ab 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -9,9 +9,9 @@ struct PayContactsView: View { @State private var isSaving = false - /// Continue's enable runs in its own task, so it can finish after the user left Pay Contacts or after a Pubky sign-out - /// stopped it. Like an import that finishes after the user left it, it opens Profile only for an applied enable while - /// Pay Contacts is still showing. + /// Continue's enable runs in its own task, so it can finish after the user left Pay Contacts, or after a Pubky sign-out + /// or a newer contact payments change stopped it. Like an import that finishes after the user left it, it opens + /// Profile only for an applied enable while Pay Contacts is still showing. static func destinationAfterEnable(isApplied: Bool, currentRoute: Route?) -> Route? { isApplied && currentRoute == .payContacts ? .profile : nil } diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index 9e745d168..a5cdf28f8 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -151,7 +151,7 @@ struct GeneralSettingsView: View { else { return } // Leaving the screen cancels `.task`; its own task lets the first contacts load and the enable still finish - // unless the Pubky session ends first. + // unless the Pubky session ends or a newer contact payments change starts first. Task { await updateContactPayments(true) } } } diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 0ac8e678f..961004fe7 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -475,7 +475,7 @@ final class ContactPaymentsServiceTests: XCTestCase { canUsePrivatePayments: true, operations: operations.makeOperations(), defaults: defaults, - isSessionCurrent: { false } + isChangeCurrent: { false } ) XCTAssertEqual(operations.calls, testCase.calls, testCase.name) @@ -488,6 +488,266 @@ final class ContactPaymentsServiceTests: XCTestCase { } } + /// The latest contact payments change wins. General Settings' first automatic enable outlives the screen, so the user + /// can turn contact payments off in a General Settings opened again while that enable still publishes endpoints. A + /// publication of the enable that gets its lock after the disable started writes nothing, and one that already held + /// its lock finishes before the disable's removal. Either way, once the disable started the enable writes no flag or + /// cleanup mark and restores nothing, it returns that it stopped, and contact payments stay off. + func testContactPaymentsDisableWinsOverAnOlderEnableStillPublishing() async throws { + enum HeldPublication { + case privateBeforeLock, publicBeforeLock, publicHoldingLock + } + let cases: [(name: String, held: HeldPublication, writes: [String])] = [ + ("private publication waiting for its lock", .privateBeforeLock, ["private:removed", "public:removed"]), + ("public publication waiting for its lock", .publicBeforeLock, ["private:published", "private:removed", "public:removed"]), + ( + "public publication holding its lock", .publicHoldingLock, + ["private:published", "private:removed", "public:published", "public:removed"] + ), + ] + let ownerKey = try useLocalPubkySecretKey() + let record = contactRecord(publicKey: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + + for testCase in cases { + try await withIsolatedDefaultsAsync { defaults in + let pubkyProfile = signedInProfile(ownerKey: ownerKey) + let contactsManager = ContactsManager(contactRecords: { [record] }) + let endpoints = LockedEndpointWrites() + let reachedHeldPoint = expectation(description: "\(testCase.name): publication reached its held point") + let disableRemovedPrivateEndpoints = expectation(description: "\(testCase.name): private endpoints removed") + let (gate, release) = AsyncStream.makeStream() + let held = testCase.held + let operations = OperationsSpy() + operations.preparePrivateEndpoints = { _, _, isChangeCurrent in + if held == .privateBeforeLock { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + return await endpoints.writePrivateReturningError("private:published", isChangeCurrent: isChangeCurrent) + } + operations.removePrivateEndpoints = { isChangeCurrent in + try await endpoints.writePrivate("private:removed", isChangeCurrent: isChangeCurrent) + disableRemovedPrivateEndpoints.fulfill() + } + operations.syncPublicEndpoints = { publish, isChangeCurrent in + if publish, held == .publicBeforeLock { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + try await endpoints.writePublic(publish ? "public:published" : "public:removed", isChangeCurrent: isChangeCurrent) { + if publish, held == .publicHoldingLock { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + } + } + let enable = Task { + try await ContactPaymentsService.setEnabled( + true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), + defaults: defaults + ) + } + await fulfillment(of: [reachedHeldPoint], timeout: 2) + + let disable = Task { + try await ContactPaymentsService.setEnabled( + false, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), + defaults: defaults + ) + } + await fulfillment(of: [disableRemovedPrivateEndpoints], timeout: 2) + // Holding its lock, the enable's publication keeps the disable's public removal waiting; otherwise the + // disable finishes while the enable is held. + if held != .publicHoldingLock { + let isDisableApplied = try await disable.value + XCTAssertTrue(isDisableApplied, testCase.name) + } + release.finish() + let enableResult = await enable.result + let disableResult = await disable.result + + XCTAssertFalse(try enableResult.get(), testCase.name) + XCTAssertTrue(try disableResult.get(), testCase.name) + XCTAssertEqual(endpoints.entries, testCase.writes, testCase.name) + XCTAssertEqual(operations.publicCleanupValues, [false], testCase.name) + XCTAssertEqual(operations.privateCleanupValues, [false], testCase.name) + XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), testCase.name) + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), testCase.name) + XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), testCase.name) + XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults), testCase.name) + } + } + } + + /// The latest contact payments change wins the other way round too. An enable that starts while an older disable still + /// removes endpoints keeps contact payments on: a removal of the disable that gets its lock after the enable started + /// removes nothing, and one that already held its lock finishes before the enable's publication. The disable writes + /// no flag or cleanup mark once the enable started, and returns that it stopped. + func testContactPaymentsEnableWinsOverAnOlderDisableStillRemoving() async throws { + enum HeldRemoval { + case privateBeforeLock, publicBeforeLock, publicHoldingLock + } + let cases: [(name: String, held: HeldRemoval, writes: [String], privateCleanupValues: [Bool])] = [ + ("private removal waiting for its lock", .privateBeforeLock, ["private:published", "public:published"], [false]), + ( + "public removal waiting for its lock", .publicBeforeLock, + ["private:removed", "private:published", "public:published"], [false, false] + ), + ( + "public removal holding its lock", .publicHoldingLock, + ["private:removed", "private:published", "public:removed", "public:published"], [false, false] + ), + ] + let ownerKey = try useLocalPubkySecretKey() + let record = contactRecord(publicKey: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + + for testCase in cases { + try await withIsolatedDefaultsAsync { defaults in + defaults.set(true, forKey: ContactPaymentsService.confirmedPreferenceKey) + defaults.set(true, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + let pubkyProfile = signedInProfile(ownerKey: ownerKey) + let contactsManager = ContactsManager(contactRecords: { [record] }) + let endpoints = LockedEndpointWrites() + let reachedHeldPoint = expectation(description: "\(testCase.name): removal reached its held point") + let enablePublishedPrivateEndpoints = expectation(description: "\(testCase.name): private endpoints published") + let (gate, release) = AsyncStream.makeStream() + let held = testCase.held + let operations = OperationsSpy() + operations.removePrivateEndpoints = { isChangeCurrent in + if held == .privateBeforeLock { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + try await endpoints.writePrivate("private:removed", isChangeCurrent: isChangeCurrent) + } + operations.preparePrivateEndpoints = { _, _, isChangeCurrent in + let error = await endpoints.writePrivateReturningError("private:published", isChangeCurrent: isChangeCurrent) + enablePublishedPrivateEndpoints.fulfill() + return error + } + operations.syncPublicEndpoints = { publish, isChangeCurrent in + if !publish, held == .publicBeforeLock { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + try await endpoints.writePublic(publish ? "public:published" : "public:removed", isChangeCurrent: isChangeCurrent) { + if !publish, held == .publicHoldingLock { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + } + } + let disable = Task { + try await ContactPaymentsService.setEnabled( + false, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), + defaults: defaults + ) + } + await fulfillment(of: [reachedHeldPoint], timeout: 2) + + let enable = Task { + try await ContactPaymentsService.setEnabled( + true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), + defaults: defaults + ) + } + await fulfillment(of: [enablePublishedPrivateEndpoints], timeout: 2) + // Holding its lock, the disable's removal keeps the enable's public publication waiting; otherwise the + // enable finishes while the disable is held. + if held != .publicHoldingLock { + let isEnableApplied = try await enable.value + XCTAssertTrue(isEnableApplied, testCase.name) + } + release.finish() + let disableResult = await disable.result + let enableResult = await enable.result + + XCTAssertFalse(try disableResult.get(), testCase.name) + XCTAssertTrue(try enableResult.get(), testCase.name) + XCTAssertEqual(endpoints.entries, testCase.writes, testCase.name) + XCTAssertEqual(operations.publicCleanupValues, [false], testCase.name) + XCTAssertEqual(operations.privateCleanupValues, testCase.privateCleanupValues, testCase.name) + XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), testCase.name) + XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), testCase.name) + XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), testCase.name) + } + } + } + + /// Opening General Settings again before its first automatic enable applied starts a second automatic enable. The older + /// one then publishes nothing, writes nothing more and returns that it stopped, and the newer one turns contact + /// payments on. + func testContactPaymentsEnableSupersedesAnOlderEnableStillPublishing() async throws { + let ownerKey = try useLocalPubkySecretKey() + let record = contactRecord(publicKey: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + + try await withIsolatedDefaultsAsync { defaults in + let pubkyProfile = signedInProfile(ownerKey: ownerKey) + let contactsManager = ContactsManager(contactRecords: { [record] }) + let endpoints = LockedEndpointWrites() + let olderReachedPublication = expectation(description: "older enable reached its private publication") + let (gate, release) = AsyncStream.makeStream() + var publicationCount = 0 + let operations = OperationsSpy() + operations.preparePrivateEndpoints = { _, _, isChangeCurrent in + publicationCount += 1 + if publicationCount == 1 { + olderReachedPublication.fulfill() + for await _ in gate {} + } + return await endpoints.writePrivateReturningError("private:published", isChangeCurrent: isChangeCurrent) + } + operations.syncPublicEndpoints = { publish, isChangeCurrent in + try await endpoints.writePublic(publish ? "public:published" : "public:removed", isChangeCurrent: isChangeCurrent) + } + let olderEnable = Task { + try await ContactPaymentsService.setEnabled( + true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), + defaults: defaults + ) + } + await fulfillment(of: [olderReachedPublication], timeout: 2) + + let isNewerEnableApplied = try await ContactPaymentsService.setEnabled( + true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), + defaults: defaults + ) + release.finish() + let isOlderEnableApplied = try await olderEnable.value + + XCTAssertTrue(isNewerEnableApplied) + XCTAssertFalse(isOlderEnableApplied) + XCTAssertEqual(endpoints.entries, ["private:published", "public:published"]) + XCTAssertEqual(operations.publicCleanupValues, [false]) + XCTAssertEqual(operations.privateCleanupValues, [false]) + XCTAssertTrue(ContactPaymentsService.isEnabled(defaults: defaults)) + } + } + + /// The endpoint removals of a change that is no longer current refuse once they hold their lock, so an older disable + /// cannot clear what a newer change published under that lock. + func testEndpointRemovalsRefuseOnceTheirChangeIsNoLongerCurrent() async { + do { + try await PrivatePaykitService().removePublishedEndpoints( + for: ["pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"], + isSessionCurrent: { false } + ) + XCTFail("Expected the private endpoint removal to refuse") + } catch PubkyServiceError.sessionNotActive { + } catch { + XCTFail("Unexpected private endpoint removal error: \(error)") + } + + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: WalletViewModel(), publish: false, isSessionCurrent: { false }) + XCTFail("Expected the public endpoint removal to refuse") + } catch PubkyServiceError.sessionNotActive { + } catch { + XCTFail("Unexpected public endpoint removal error: \(error)") + } + } + /// Pay Contacts' Continue enables contact payments in a task that outlives the screen. Like an import that finishes /// after the user left it, it opens Profile only for an applied enable while Pay Contacts is still showing. func testPayContactsOpensProfileOnlyForAnAppliedEnableWhilePayContactsIsShowing() { @@ -577,6 +837,43 @@ final class ContactPaymentsServiceTests: XCTestCase { } } + /// Stands in for the Paykit services' endpoint writes. Each takes the lock its real counterpart takes and, like it, + /// refuses once that lock is held for a change that is no longer current. + @MainActor + private final class LockedEndpointWrites { + private let privatePaykit = PrivatePaykitService() + private(set) var entries: [String] = [] + + func writePrivate(_ entry: String, isChangeCurrent: @escaping ContactPaymentsService.ChangeCheck) async throws { + try await privatePaykit.withPublicationLock { + guard isChangeCurrent() else { + throw PubkyServiceError.sessionNotActive + } + entries.append(entry) + } + } + + func writePrivateReturningError(_ entry: String, isChangeCurrent: @escaping ContactPaymentsService.ChangeCheck) async -> Error? { + do { + try await writePrivate(entry, isChangeCurrent: isChangeCurrent) + return nil + } catch { + return error + } + } + + func writePublic( + _ entry: String, + isChangeCurrent: @escaping ContactPaymentsService.ChangeCheck, + whileLocked: () async -> Void = {} + ) async throws { + try await PublicPaykitService.withEndpointLock(unlessSessionEnded: isChangeCurrent) { + await whileLocked() + entries.append(entry) + } + } + } + private final class OperationsSpy { struct PrivatePublication { let contactPublicKeys: [String] @@ -593,20 +890,21 @@ final class ContactPaymentsServiceTests: XCTestCase { var privatePublicationFailures: Set = [] var privateRemovalFailures: Set = [] var onPreparePrivateEndpoints: (() -> Void)? - var preparePrivateEndpoints: (([String], Bool, @escaping ContactPaymentsService.SessionCheck) async -> Error?)? - var syncPublicEndpoints: ((Bool, @escaping ContactPaymentsService.SessionCheck) async throws -> Void)? + var preparePrivateEndpoints: (([String], Bool, @escaping ContactPaymentsService.ChangeCheck) async -> Error?)? + var syncPublicEndpoints: ((Bool, @escaping ContactPaymentsService.ChangeCheck) async throws -> Void)? + var removePrivateEndpoints: ((@escaping ContactPaymentsService.ChangeCheck) async throws -> Void)? func makeOperations() -> ContactPaymentsService.Operations { ContactPaymentsService.Operations( - syncPublicEndpoints: { publish, isSessionCurrent in + syncPublicEndpoints: { publish, isChangeCurrent in self.calls.append("public:\(publish)") self.publicPublicationValues.append(publish) - try await self.syncPublicEndpoints?(publish, isSessionCurrent) + try await self.syncPublicEndpoints?(publish, isChangeCurrent) if self.publicPublicationFailures.contains(self.publicPublicationValues.count) { throw TestError.operationFailed } }, - preparePrivateEndpoints: { contactPublicKeys, requiresImmediatePublication, isSessionCurrent in + preparePrivateEndpoints: { contactPublicKeys, requiresImmediatePublication, isChangeCurrent in self.onPreparePrivateEndpoints?() self.calls.append("private:publish") self.privatePublications.append( @@ -616,16 +914,17 @@ final class ContactPaymentsServiceTests: XCTestCase { ) ) if let preparePrivateEndpoints = self.preparePrivateEndpoints { - return await preparePrivateEndpoints(contactPublicKeys, requiresImmediatePublication, isSessionCurrent) + return await preparePrivateEndpoints(contactPublicKeys, requiresImmediatePublication, isChangeCurrent) } return self.privatePublicationFailures.contains(self.privatePublications.count) ? TestError.operationFailed : nil }, - removePrivateEndpoints: { + removePrivateEndpoints: { isChangeCurrent in self.calls.append("private:remove") self.privateRemovalCount += 1 if self.privateRemovalFailures.contains(self.privateRemovalCount) { throw TestError.operationFailed } + try await self.removePrivateEndpoints?(isChangeCurrent) }, setPublicCleanupPending: { self.publicCleanupValues.append($0) }, setPrivateCleanupPending: { self.privateCleanupValues.append($0) }