Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0dc56a0
fix: stop automatic profile deletion replay
ben-kaufman Oct 24, 2026
1b8a6db
docs: associate deletion fix with PR 1122
ben-kaufman Oct 24, 2026
be554fc
Merge backup lifecycle repair into profile deletion recovery
ben-kaufman Oct 25, 2026
d1745fb
Merge backup restart recovery into profile deletion fix
ben-kaufman Oct 25, 2026
6577437
Merge parent UI synchronization into profile deletion fix
ben-kaufman Oct 25, 2026
ca10ace
test: retain navigation timeout evidence
ben-kaufman Oct 25, 2026
d66019e
Merge backup lifecycle parent into profile deletion recovery
ben-kaufman Oct 27, 2026
7f54d7b
Align profile recovery with current parent ancestry
ben-kaufman Oct 27, 2026
162ea52
Merge validated foreground backup parent
ben-kaufman Oct 27, 2026
f3df6cd
merge validated backup lifecycle parent
ben-kaufman Oct 28, 2026
940d7f3
Merge current backup lifecycle parent into profile deletion
ben-kaufman Oct 29, 2026
bee8975
Merge validated backup parent into profile deletion recovery
ben-kaufman Oct 29, 2026
49f88a0
Merge backup lifecycle guards into profile session recovery
ben-kaufman Oct 30, 2026
a6647db
Merge validated backup lifecycle parent
ben-kaufman Oct 31, 2026
2f80b44
Merge queued allowance admission into profile recovery
ben-kaufman Nov 1, 2026
5a5044a
Merge validated backup parent into restore identity recovery
ben-kaufman Nov 1, 2026
2d60492
merge: preserve cached backups during session recovery
ben-kaufman Nov 3, 2026
595898e
Merge scheduled backup completion fix into profile session recovery
ben-kaufman Nov 3, 2026
b44f025
merge: compose backup auth recovery and clipboard fixture
ben-kaufman Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 0 additions & 52 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1619,14 +1619,6 @@ class PubkyProfileManager: ObservableObject {
}
}

func refreshSessionIfPossible(after error: Error) async -> Bool {
await Self.refreshSessionIfPossible(
after: error,
loadKeychainString: { try Keychain.loadString(key: $0) },
signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) }
)
}

// MARK: - Cached Profile Metadata

private static let cachedNameKey = "pubky_profile_name"
Expand Down Expand Up @@ -2004,50 +1996,6 @@ class PubkyProfileManager: ObservableObject {
return false
}

nonisolated static func isSessionRefreshableError(_ error: Error) -> Bool {
let errorText = [
(error as? AppError)?.debugMessage,
error.localizedDescription,
String(describing: error),
]
.compactMap { $0?.lowercased() }

return errorText.contains {
($0.contains("authfailed") || $0.contains("authentication failed") || $0.contains("sessionnotactive"))
|| ($0.contains("transport error") && $0.contains("/session"))
}
}

nonisolated static func refreshSessionIfPossible(
after error: Error,
loadKeychainString: (KeychainEntryType) throws -> String? = {
try Keychain.loadString(key: $0)
},
signInWithSecretKey: (String) async throws -> String,
publicKeyFromSecretKey: (String) throws -> String = {
try PubkyProfileManager.publicKeyFromSecretKey($0)
}
) async -> Bool {
guard isSessionRefreshableError(error) else {
return false
}

guard let secretKeyHex = activeSecretKeyHex(loadKeychainString: loadKeychainString) else {
Logger.warn("Cannot refresh pubky session without a secret key", context: "PubkyProfileManager")
return false
}

do {
_ = try await signInWithSecretKey(secretKeyHex)
_ = try publicKeyFromSecretKey(secretKeyHex)
Logger.info("Refreshed pubky session from local secret key", context: "PubkyProfileManager")
return true
} catch {
Logger.warn("Failed to refresh pubky session: \(error)", context: "PubkyProfileManager")
return false
}
}

nonisolated static func resolveSessionInitialization(
savedSessionSecret: String?,
storedSecretKeyHex: String?,
Expand Down
24 changes: 21 additions & 3 deletions Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
@StateObject private var navigation = NavigationViewModel()
@StateObject private var contacts = ContactsManager()
@State private var clipboard = ""
@State private var clipboardEvents: [String] = []

var body: some View {
VStack(spacing: 8) {
Expand All @@ -18,16 +19,23 @@
.accessibilityIdentifier("ProfileFixtureFail")
Button("Load") { profile.finish(success: true) }
.accessibilityIdentifier("ProfileFixtureLoad")
Button("Clipboard") { clipboard = UIPasteboard.general.string ?? "" }
.accessibilityIdentifier("ProfileFixtureClipboard")
PasteButton(payloadType: String.self) { values in
clipboard = values.first ?? ""
let kind = clipboard == "not-copied" ? "sentinel" :
clipboard == ProfileRecoveryTestManager.savedProfile.publicKey ? "profile" : "other"
recordClipboard("paste \(kind); change=\(UIPasteboard.general.changeCount)")
}
.accessibilityIdentifier("ProfileFixtureClipboard")
Button("Reset copy") {
UIPasteboard.general.string = "not-copied"
clipboard = "not-copied"
clipboard = ""
recordClipboard("reset; change=\(UIPasteboard.general.changeCount)")
}
.accessibilityIdentifier("ProfileFixtureResetClipboard")
}
CaptionText(clipboard)
.accessibilityIdentifier("ProfileFixtureClipboardValue")
.accessibilityValue(clipboardEvents.joined(separator: "\n"))
CaptionText(profile.isLoadingProfile ? "Loading" : profile.isDisconnectPending ? "Disconnecting" : "Idle")
.accessibilityIdentifier("ProfileFixtureStatus")
.accessibilityValue(profile.events.joined(separator: "\n"))
Expand All @@ -46,6 +54,16 @@
.environmentObject(navigation)
.environmentObject(contacts)
.preferredColorScheme(.dark)
.onReceive(NotificationCenter.default.publisher(for: UIPasteboard.changedNotification)) { _ in
recordClipboard("changed; change=\(UIPasteboard.general.changeCount)")
}
}

private func recordClipboard(_ event: String) {
clipboardEvents.append(event)
if clipboardEvents.count > 40 {
clipboardEvents.removeFirst()
}
}
}

Expand Down
29 changes: 7 additions & 22 deletions Bitkit/Views/Profile/EditProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,33 +158,18 @@ struct EditProfileView: View {
defer { isDeleting = false }
do {
try await pubkyProfile.performProfileDeletion {
await deleteProfileWithSessionRetry(session: session)
}
} catch {
guard !Task.isCancelled, pubkyProfile.currentSession == session else { return }
showDeleteFailureOptions = true
}
}

private func deleteProfileWithSessionRetry(session: PubkyProfileManager.SignedInSession) async {
do {
try await performDeleteProfile(session: session)
} catch {
guard !Task.isCancelled, pubkyProfile.publicKey == session.publicKey else { return }
if pubkyProfile.currentSession == session, await pubkyProfile.refreshSessionIfPossible(after: error) {
do {
try await performDeleteProfile(session: session)
return
} catch {
Logger.error("Failed to delete profile after session refresh: \(error)", context: "EditProfileView")
guard !Task.isCancelled, pubkyProfile.publicKey == session.publicKey else { return }
Logger.error("Failed to delete profile: \(error)", context: "EditProfileView")
showDeleteFailureOptions = true
}
} else {
Logger.error("Failed to delete profile: \(error)", context: "EditProfileView")
}

if !Task.isCancelled, pubkyProfile.publicKey == session.publicKey {
showDeleteFailureOptions = true
}
} catch {
guard !Task.isCancelled, pubkyProfile.currentSession == session else { return }
Logger.error("Failed to delete profile: \(error)", context: "EditProfileView")
showDeleteFailureOptions = true
}
}

Expand Down
59 changes: 0 additions & 59 deletions BitkitTests/PubkyProfileManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3578,65 +3578,6 @@ final class PubkyProfileManagerTests: XCTestCase {
XCTAssertFalse(PubkyProfileManager.isMissingBitkitProfileStorageError(error))
}

func testIsSessionRefreshableErrorRecognizesSessionTransportFailure() {
let error = AppError(
message: "App Error",
debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error: error sending request for url (https://example.com/session)")"#
)

XCTAssertTrue(PubkyProfileManager.isSessionRefreshableError(error))
}

func testRefreshSessionIfPossibleRefreshesSessionFromLocalSecret() async {
let error = AppError(
message: "App Error",
debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error: error sending request for url (https://example.com/session)")"#
)
let refreshed = await PubkyProfileManager.refreshSessionIfPossible(
after: error,
loadKeychainString: { key in
switch key {
case .pubkySecretKey:
return "local-secret"
default:
return nil
}
},
signInWithSecretKey: { secretKey in
XCTAssertEqual(secretKey, "local-secret")
return "fresh-session"
},
publicKeyFromSecretKey: { secretKey in
XCTAssertEqual(secretKey, "local-secret")
return "pubky_fresh"
}
)

XCTAssertTrue(refreshed)
}

func testRefreshSessionIfPossibleReturnsFalseWithoutLocalSecret() async {
let error = AppError(
message: "App Error",
debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error: error sending request for url (https://example.com/session)")"#
)

let refreshed = await PubkyProfileManager.refreshSessionIfPossible(
after: error,
loadKeychainString: { _ in nil },
signInWithSecretKey: { _ in
XCTFail("Expected refresh to stop when no local secret key exists")
return "fresh-session"
},
publicKeyFromSecretKey: { _ in
XCTFail("No public key should be derived without a local secret")
return "pubky_unused"
}
)

XCTAssertFalse(refreshed)
}

// MARK: - Session backup state

func testSnapshotSessionBackupStatePrefersLocalSeedOverSessionSecret() throws {
Expand Down
37 changes: 36 additions & 1 deletion BitkitTests/SendConfirmationLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,42 @@ private final class SendConfirmationTestNavigation {
while visibleRoutes != [route], ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
}
XCTAssertEqual(visibleRoutes, [route], file: file, line: line)
let observedRoutes = visibleRoutes
XCTAssertEqual(observedRoutes, [route], file: file, line: line)
guard observedRoutes != [route] else { return }

XCTContext.runActivity(named: "Navigation appearance deadline") { activity in
let state = XCTAttachment(string: "expected=\(route) visible=\(observedRoutes) path=\(path)")
state.name = "navigation-state"
state.lifetime = .keepAlways
activity.add(state)

func describe(_ view: UIView, depth: Int = 0) -> String {
let row = "\(String(repeating: " ", count: depth))\(type(of: view)) frame=\(view.frame) hidden=\(view.isHidden) firstResponder=\(view.isFirstResponder)\n"
return row + view.subviews.map { describe($0, depth: depth + 1) }.joined()
}
func describe(_ controller: UIViewController, depth: Int = 0) -> String {
let row = "\(String(repeating: " ", count: depth))\(type(of: controller)) appearing=\(controller.isBeingPresented) disappearing=\(controller.isBeingDismissed) transitioning=\(controller.transitionCoordinator != nil)\n"
let children = controller.children.map { describe($0, depth: depth + 1) }.joined()
let presented = controller.presentedViewController.map { describe($0, depth: depth + 1) } ?? ""
return row + children + presented
}
let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows)
for (index, window) in windows.enumerated() {
let controllers = window.rootViewController.map { describe($0) } ?? "No root view controller\n"
let hierarchy = XCTAttachment(string: "key=\(window.isKeyWindow)\n" + controllers + describe(window))
hierarchy.name = "deadline-window-\(index)-hierarchy"
hierarchy.lifetime = .keepAlways
activity.add(hierarchy)
guard !window.isHidden, !window.bounds.isEmpty else { continue }
let screenshot = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
})
screenshot.name = "deadline-window-\(index)"
screenshot.lifetime = .keepAlways
activity.add(screenshot)
}
}
}
}

Expand Down
10 changes: 9 additions & 1 deletion BitkitUITests/ProfileRecoveryUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ final class ProfileRecoveryUITests: XCTestCase {
for id in ["ProfileCopy", "ProfileQRCode"] {
scrollToTop()
app.buttons["ProfileFixtureResetClipboard"].tap()
app.buttons["ProfileFixtureClipboard"].tap()
waitForClipboard("not-copied")
let copy = app.buttons[id]
XCTAssertTrue(copy.isEnabled)
Expand Down Expand Up @@ -115,7 +116,14 @@ final class ProfileRecoveryUITests: XCTestCase {
private func waitForClipboard(_ value: String) {
let clipboard = app.staticTexts["ProfileFixtureClipboardValue"]
let matches = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == %@", value), object: clipboard)
XCTAssertEqual(XCTWaiter.wait(for: [matches], timeout: 5), .completed)
let result = XCTWaiter.wait(for: [matches], timeout: 5)
if result != .completed {
let trace = XCTAttachment(string: "Expected fixture value: \(value)\n\(clipboard.value as? String ?? "No clipboard trace")")
trace.name = "Profile recovery clipboard events"
trace.lifetime = .keepAlways
add(trace)
}
XCTAssertEqual(result, .completed)
}

private func scrollToTop() {
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1122.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Prevent uncertain profile deletion failures from automatically repeating contact cleanup and deletion.
17 changes: 17 additions & 0 deletions journeys/profile/delete-profile-uncertain-response.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<journey name="profile deletion uncertain response">
<description>
Requires a disposable test identity and a controlled homeserver proxy; never use a personal profile.
Configure the proxy to accept one profile deletion but lose its acknowledgement, retaining a redacted
request counter. This is a fault-injection fixture requirement, not evidence that this journey has run.
Normal SDK recovery may retry an individual request only after a confirmed authentication rejection;
the app must not restart contact cleanup and profile deletion because a request has an uncertain outcome.
</description>
<actions>
<action>Open Edit Profile and confirm deletion</action>
<action>Drop the acknowledgement after the server accepts the profile deletion</action>
<action>Verify failure options are shown and no success notification or onboarding navigation occurs</action>
<action>Wait through the network timeout and verify the app starts no second contact cleanup, profile deletion or sign-in</action>
<action>Cancel the failure dialog and verify no mutation is retried</action>
<action>Restart the app without choosing Retry and verify it does not automatically repeat the deletion</action>
</actions>
</journey>
Loading