diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 0a5ea51ef..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(xcpretty:*)", - "Bash(gt submit:*)", - "Bash(timeout 60 swift test:*)", - "Bash(git add:*)", - "mcp__graphite__run_gt_cmd", - "Bash(git merge:*)", - "Bash(gt add:*)", - "Bash(gt continue:*)", - "Bash(gt log:*)" - ] - }, - "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "XcodeBuildMCP", - "ios-simulator", - "xmtp-docs", - "graphite", - "notion", - "linear" - ] -} diff --git a/Convos.xcodeproj/project.pbxproj b/Convos.xcodeproj/project.pbxproj index 9958a1d3d..af8c980c2 100644 --- a/Convos.xcodeproj/project.pbxproj +++ b/Convos.xcodeproj/project.pbxproj @@ -211,7 +211,6 @@ "Conversation Detail/Messages/Messages Collection Layout/Models/SectionModel.swift", "Conversation Detail/Messages/Messages View Controller/Constants.swift", "Conversation Detail/Messages/Messages View Controller/Helpers/ManualAnimator.swift", - "Conversation Detail/Messages/Messages View Controller/Helpers/MessagesDateFormatter.swift", "Conversation Detail/Messages/Messages View Controller/Helpers/SetActor.swift", "Conversation Detail/Messages/Messages View Controller/Helpers/UICollectionView+DifferenceKit.swift", "Conversation Detail/Messages/Messages View Controller/Helpers/UIView+Extension.swift", diff --git a/Convos/App Settings/AppSettingsView.swift b/Convos/App Settings/AppSettingsView.swift index 363dbd8df..ccb28e509 100644 --- a/Convos/App Settings/AppSettingsView.swift +++ b/Convos/App Settings/AppSettingsView.swift @@ -33,6 +33,7 @@ struct AppSettingsView: View { @Bindable var viewModel: AppSettingsViewModel @Bindable var quicknameViewModel: QuicknameSettingsViewModel let session: any SessionManagerProtocol + var databaseManager: (any DatabaseManagerProtocol)? let onDeleteAllData: () -> Void @State private var showingDeleteAllDataConfirmation: Bool = false @Environment(\.openURL) private var openURL: OpenURLAction @@ -147,59 +148,7 @@ struct AppSettingsView: View { } .listRowSeparatorTint(.colorBorderSubtle) - Section { - Button { - openExternalURL("https://xmtp.org") - } label: { - NavigationLink { - EmptyView() - } label: { - HStack(alignment: .firstTextBaseline, spacing: 0.0) { - Text("Secured by ") - Image("xmtpIcon") - .renderingMode(.template) - .foregroundStyle(.colorTextPrimary) - .padding(.trailing, 1.0) - Text("XMTP") - } - .foregroundStyle(.colorTextPrimary) - } - } - .foregroundStyle(.colorTextPrimary) - - Button { - openExternalURL("https://hq.convos.org/privacy-and-terms") - } label: { - NavigationLink("Privacy & Terms", destination: EmptyView()) - } - .foregroundStyle(.colorTextPrimary) - - Button { - sendFeedback() - } label: { - Text("Send feedback") - } - .foregroundStyle(.colorTextPrimary) - - if !ConfigManager.shared.currentEnvironment.isProduction { - NavigationLink { - DebugExportView(environment: ConfigManager.shared.currentEnvironment, session: session) - } label: { - Text("Debug") - } - .accessibilityIdentifier("debug-row") - .foregroundStyle(.colorTextPrimary) - } - } footer: { - HStack { - Text("Made in the open by XMTP Labs") - Spacer() - Text("V\(Bundle.appVersion)") - .foregroundStyle(.colorTextTertiary) - } - .foregroundStyle(.colorTextSecondary) - } - .listRowSeparatorTint(.colorBorderSubtle) + aboutSection Section { Button(role: .destructive) { @@ -243,6 +192,63 @@ struct AppSettingsView: View { } } + @ViewBuilder + private var aboutSection: some View { + Section { + Button { + openExternalURL("https://xmtp.org") + } label: { + NavigationLink { + EmptyView() + } label: { + HStack(alignment: .firstTextBaseline, spacing: 0.0) { + Text("Secured by ") + Image("xmtpIcon") + .renderingMode(.template) + .foregroundStyle(.colorTextPrimary) + .padding(.trailing, 1.0) + Text("XMTP") + } + .foregroundStyle(.colorTextPrimary) + } + } + .foregroundStyle(.colorTextPrimary) + + Button { + openExternalURL("https://hq.convos.org/privacy-and-terms") + } label: { + NavigationLink("Privacy & Terms", destination: EmptyView()) + } + .foregroundStyle(.colorTextPrimary) + + Button { + sendFeedback() + } label: { + Text("Send feedback") + } + .foregroundStyle(.colorTextPrimary) + + if !ConfigManager.shared.currentEnvironment.isProduction { + NavigationLink { + DebugExportView(environment: ConfigManager.shared.currentEnvironment, session: session, databaseManager: databaseManager) + } label: { + Text("Debug") + } + .accessibilityIdentifier("debug-row") + .foregroundStyle(.colorTextPrimary) + } + } footer: { + HStack { + Text("Made in the open by XMTP Labs") + Spacer() + Text("V\(Bundle.appVersion)") + .foregroundStyle(.colorTextTertiary) + } + .foregroundStyle(.colorTextSecondary) + } + .listRowSeparatorTint(.colorBorderSubtle) + } + private func sendFeedback() { let email = "convos@xmtp.com" let subject = "Convos Feedback" diff --git a/Convos/Config/Dev.xcconfig b/Convos/Config/Dev.xcconfig index c5891cd52..b029dfac0 100644 --- a/Convos/Config/Dev.xcconfig +++ b/Convos/Config/Dev.xcconfig @@ -27,6 +27,7 @@ NOTIFICATION_SERVICE_BUNDLE_ID = $(MAIN_BUNDLE_ID).ConvosNSE // Environment-specific entitlements APP_GROUP_IDENTIFIER = group.$(MAIN_BUNDLE_ID) +ICLOUD_CONTAINER_IDENTIFIER = iCloud.$(MAIN_BUNDLE_ID) ASSOCIATED_DOMAIN = dev.convos.org WEB_CREDENTIALS_DOMAIN = dev.convos.org SECONDARY_ASSOCIATED_DOMAIN = app-dev.convos.org diff --git a/Convos/Config/Local.xcconfig b/Convos/Config/Local.xcconfig index aeae3084b..b82371752 100644 --- a/Convos/Config/Local.xcconfig +++ b/Convos/Config/Local.xcconfig @@ -25,6 +25,7 @@ NOTIFICATION_SERVICE_BUNDLE_ID = $(MAIN_BUNDLE_ID).ConvosNSE // Environment-specific entitlements APP_GROUP_IDENTIFIER = group.$(MAIN_BUNDLE_ID) +ICLOUD_CONTAINER_IDENTIFIER = iCloud.$(MAIN_BUNDLE_ID) ASSOCIATED_DOMAIN = local.convos.org WEB_CREDENTIALS_DOMAIN = local.convos.org SECONDARY_ASSOCIATED_DOMAIN = app-local.convos.org diff --git a/Convos/Config/Prod.xcconfig b/Convos/Config/Prod.xcconfig index e0207ce05..53f7713c0 100644 --- a/Convos/Config/Prod.xcconfig +++ b/Convos/Config/Prod.xcconfig @@ -17,6 +17,7 @@ NOTIFICATION_SERVICE_BUNDLE_ID = $(MAIN_BUNDLE_ID).ConvosNSE // Environment-specific entitlements APP_GROUP_IDENTIFIER = group.$(MAIN_BUNDLE_ID) +ICLOUD_CONTAINER_IDENTIFIER = iCloud.$(MAIN_BUNDLE_ID) ASSOCIATED_DOMAIN = popup.convos.org WEB_CREDENTIALS_DOMAIN = convos.org SECONDARY_ASSOCIATED_DOMAIN = app.convos.org diff --git a/Convos/Conversation Detail/ConversationInfoView.swift b/Convos/Conversation Detail/ConversationInfoView.swift index 564bcd05b..58b45731e 100644 --- a/Convos/Conversation Detail/ConversationInfoView.swift +++ b/Convos/Conversation Detail/ConversationInfoView.swift @@ -329,6 +329,39 @@ struct ConversationInfoView: View { } } + @ViewBuilder + private var comingSoonSections: some View { + Section { + HStack { + Text("Vanish") + .foregroundStyle(.colorTextPrimary) + Spacer() + SoonLabel() + } + } footer: { + Text("Choose when this convo disappears from your device") + .foregroundStyle(.colorTextSecondary) + } + .disabled(true) + + Section { + NavigationLink { + EmptyView() + } label: { + HStack { + Text("Permissions") + .foregroundStyle(.colorTextPrimary) + Spacer() + SoonLabel() + } + } + .disabled(true) + } footer: { + Text("Choose who can manage the group") + .foregroundStyle(.colorTextSecondary) + } + } + var body: some View { NavigationStack { List { @@ -356,35 +389,7 @@ struct ConversationInfoView: View { convoRulesSection - Section { - HStack { - Text("Vanish") - .foregroundStyle(.colorTextPrimary) - Spacer() - SoonLabel() - } - } footer: { - Text("Choose when this convo disappears from your device") - .foregroundStyle(.colorTextSecondary) - } - .disabled(true) - - Section { - NavigationLink { - EmptyView() - } label: { - HStack { - Text("Permissions") - .foregroundStyle(.colorTextPrimary) - Spacer() - SoonLabel() - } - } - .disabled(true) - } footer: { - Text("Choose who can manage the group") - .foregroundStyle(.colorTextSecondary) - } + comingSoonSections if !ConfigManager.shared.currentEnvironment.isProduction { Section { diff --git a/Convos/Conversation Detail/ConversationView.swift b/Convos/Conversation Detail/ConversationView.swift index 16a838ee1..bdcafa100 100644 --- a/Convos/Conversation Detail/ConversationView.swift +++ b/Convos/Conversation Detail/ConversationView.swift @@ -18,9 +18,11 @@ struct ConversationView: View { @State private var showingProcessingPowerInfo: Bool = false @State private var showingFullInfo: Bool = false @State private var showingAssistantsInfo: Bool = false + @State private var showingReconnectionAlert: Bool = false @State private var scrollOverscrollAmount: CGFloat = 0.0 @State private var didReleasePastThreshold: Bool = false @Environment(\.dismiss) private var dismiss: DismissAction + @Environment(\.openURL) private var openURL: OpenURLAction private var showPullToAddAssistant: Bool { !viewModel.conversation.hasAgent @@ -64,18 +66,38 @@ struct ConversationView: View { viewModel.onProfilePhotoTap(focusCoordinator: focusCoordinator) }, onSendMessage: { - viewModel.onSendMessage(focusCoordinator: focusCoordinator) + if viewModel.isInactive { + showingReconnectionAlert = true + } else { + viewModel.onSendMessage(focusCoordinator: focusCoordinator) + } }, onClearInvite: viewModel.clearPendingInvite, onClearLinkPreview: { viewModel.pastedLinkPreview = nil }, onTapAvatar: viewModel.onTapAvatar(_:), onTapInvite: viewModel.onTapInvite(_:), - onReaction: viewModel.onReaction(emoji:messageId:), - onToggleReaction: viewModel.onReaction(emoji:messageId:), + onReaction: { emoji, messageId in + if viewModel.isInactive { + showingReconnectionAlert = true + } else { + viewModel.onReaction(emoji: emoji, messageId: messageId) + } + }, + onToggleReaction: { emoji, messageId in + if viewModel.isInactive { + showingReconnectionAlert = true + } else { + viewModel.onReaction(emoji: emoji, messageId: messageId) + } + }, onTapReactions: viewModel.onTapReactions(_:), onReply: { message in - viewModel.onReply(message) - focusCoordinator.moveFocus(to: .message) + if viewModel.isInactive { + showingReconnectionAlert = true + } else { + viewModel.onReply(message) + focusCoordinator.moveFocus(to: .message) + } }, replyingToMessage: viewModel.replyingToMessage, onCancelReply: viewModel.cancelReply, @@ -143,6 +165,14 @@ struct ConversationView: View { bottomBarContent() + if viewModel.isInactive { + InactiveConversationBanner { + if let url = URL(string: "https://learn.convos.org/") { + openURL(url) + } + } + } + ConversationOnboardingView( coordinator: onboardingCoordinator, focusCoordinator: focusCoordinator, @@ -197,6 +227,14 @@ struct ConversationView: View { viewModel.onProfileSettingsDismissed(focusCoordinator: focusCoordinator) } } + .alert( + "Awaiting reconnection", + isPresented: $showingReconnectionAlert + ) { + Button("Got it", role: .cancel) {} + } message: { + Text("You can see and send new messages, reactions and more after another member sends a message.") + } .toolbar { ToolbarItem(placement: .topBarTrailing) { if viewModel.isLocked { diff --git a/Convos/Conversation Detail/ConversationViewModel.swift b/Convos/Conversation Detail/ConversationViewModel.swift index faa5ce54a..686dbc5c5 100644 --- a/Convos/Conversation Detail/ConversationViewModel.swift +++ b/Convos/Conversation Detail/ConversationViewModel.swift @@ -268,6 +268,10 @@ class ConversationViewModel { conversation.isLocked } + var isInactive: Bool { + !conversation.isActive + } + var isFull: Bool { conversation.isFull } @@ -514,7 +518,9 @@ class ConversationViewModel { if conversation.isPendingInvite { onboardingCoordinator.isWaitingForInviteAcceptance = true } - startOnboarding() + if !isInactive { + startOnboarding() + } registerInlineAttachmentRecovery() scheduleVoiceMemoTranscriptionsIfNeeded(in: messages) } diff --git a/Convos/Conversation Detail/InactiveConversationBanner.swift b/Convos/Conversation Detail/InactiveConversationBanner.swift new file mode 100644 index 000000000..2f3e818a5 --- /dev/null +++ b/Convos/Conversation Detail/InactiveConversationBanner.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct InactiveConversationBanner: View { + let onTap: () -> Void + + var body: some View { + let action = onTap + Button(action: action) { + VStack(spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "cloud.fill") + .foregroundStyle(.colorLava) + .font(.callout) + Text("Restored from backup") + .font(.callout) + .foregroundStyle(.colorTextPrimary) + } + Text("You can see and send new messages after another member sends a message") + .font(.system(size: 14)) + .foregroundStyle(.colorTextSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(24) + .background(Color.colorFillMinimal, in: RoundedRectangle(cornerRadius: 24)) + } + .buttonStyle(.plain) + .accessibilityLabel("Restored from backup. You can see and send new messages after another member sends a message.") + .accessibilityIdentifier("inactive-conversation-banner") + } +} + +#Preview { + InactiveConversationBanner(onTap: {}) + .padding() +} diff --git a/Convos/Conversations List/ConversationsListItem.swift b/Convos/Conversations List/ConversationsListItem.swift index be4617b5a..70d9fe33f 100644 --- a/Convos/Conversations List/ConversationsListItem.swift +++ b/Convos/Conversations List/ConversationsListItem.swift @@ -77,10 +77,12 @@ struct ConversationsListItem: View { private var createdAt: Date { conversation.createdAt } private var isPendingInvite: Bool { conversation.isPendingInvite } + private var isInactive: Bool { !conversation.isActive } private var accessibilityDescription: String { var parts: [String] = [title] if isPendingInvite { parts.append("verifying") } + if isInactive { parts.append("awaiting") } if isUnread { parts.append("unread") } if isMuted { parts.append("muted") } if let message = lastMessage { @@ -89,11 +91,13 @@ struct ConversationsListItem: View { return parts.joined(separator: ", ") } + private var isInteractionDisabled: Bool { isPendingInvite || isInactive } + var body: some View { ListItemView( title: title, - isMuted: isPendingInvite ? false : isMuted, - isUnread: isPendingInvite ? false : isUnread, + isMuted: isInteractionDisabled ? false : isMuted, + isUnread: isInteractionDisabled ? false : isUnread, leadingContent: { ConversationAvatarView(conversation: conversation, conversationImage: nil) }, @@ -103,6 +107,10 @@ struct ConversationsListItem: View { RelativeDateLabel(date: createdAt) Text("·").foregroundStyle(.colorTextTertiary) Text("Verifying") + } else if isInactive { + RelativeDateLabel(date: lastMessage?.createdAt ?? createdAt) + Text("·").foregroundStyle(.colorTextTertiary) + Text("Awaiting") } else if let message = lastMessage { RelativeDateLabel(date: message.createdAt) Text("·").foregroundStyle(.colorTextTertiary) diff --git a/Convos/Conversations List/ConversationsView.swift b/Convos/Conversations List/ConversationsView.swift index 691bfdf35..4380872ad 100644 --- a/Convos/Conversations List/ConversationsView.swift +++ b/Convos/Conversations List/ConversationsView.swift @@ -13,6 +13,7 @@ struct ConversationsView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme @State private var conversationPendingExplosion: Conversation? @State private var preferredColumn: NavigationSplitViewColumn = .sidebar + @State private var presentingStaleDeviceInfo: Bool = false var focusCoordinator: FocusCoordinator { viewModel.focusCoordinator @@ -33,6 +34,21 @@ struct ConversationsView: View { ) } + var staleDeviceEmptyView: some View { + VStack(spacing: DesignConstants.Spacing.step2x) { + Text("This device has been replaced") + .font(.headline) + .foregroundStyle(.colorTextPrimary) + Text("Your conversations are no longer accessible on this device.") + .font(.subheadline) + .foregroundStyle(.colorTextSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, DesignConstants.Spacing.step6x) + .padding(.top, DesignConstants.Spacing.step6x) + } + var filteredEmptyStateView: some View { FilteredEmptyStateView( message: viewModel.activeFilter.emptyStateMessage, @@ -44,13 +60,17 @@ struct ConversationsView: View { } var conversationsCollectionView: some View { - ConversationsViewRepresentable( + let onStartConvo: (() -> Void)? = viewModel.canStartOrJoinConversations ? { viewModel.onStartConvo() } : nil + let onJoinConvo: (() -> Void)? = viewModel.canStartOrJoinConversations ? { viewModel.onJoinConvo() } : nil + + return ConversationsViewRepresentable( pinnedConversations: viewModel.pinnedConversations, unpinnedConversations: viewModel.unpinnedConversations, selectedConversationId: viewModel.selectedConversationId, isFilteredResultEmpty: viewModel.isFilteredResultEmpty, filterEmptyMessage: viewModel.activeFilter.emptyStateMessage, hasCreatedMoreThanOneConvo: viewModel.hasCreatedMoreThanOneConvo, + isDeviceStale: viewModel.isDeviceStale, onSelectConversation: { conversation in viewModel.selectedConversationId = conversation.id }, @@ -69,8 +89,8 @@ struct ConversationsView: View { onTogglePin: { conversation in viewModel.togglePin(conversation: conversation) }, - onStartConvo: viewModel.onStartConvo, - onJoinConvo: viewModel.onJoinConvo, + onStartConvo: onStartConvo, + onJoinConvo: onJoinConvo, onShowAllFilter: { viewModel.activeFilter = .all } ) .ignoresSafeArea(edges: [.top, .bottom]) @@ -202,12 +222,16 @@ struct ConversationsView: View { onScanInviteCode: {}, onDeleteConversation: {}, messagesTopBarTrailingItem: .share, - messagesTopBarTrailingItemEnabled: !conversationViewModel.conversation.isPendingInvite, - messagesTextFieldEnabled: !conversationViewModel.conversation.isPendingInvite, + messagesTopBarTrailingItemEnabled: !conversationViewModel.conversation.isPendingInvite && conversationViewModel.conversation.isActive, + messagesTextFieldEnabled: !conversationViewModel.conversation.isPendingInvite && conversationViewModel.conversation.isActive, bottomBarContent: { EmptyView() } ) } else if horizontalSizeClass != .compact { - emptyConversationsViewScrollable + if viewModel.isDeviceStale { + staleDeviceEmptyView + } else { + emptyConversationsViewScrollable + } } else { EmptyView() } diff --git a/Convos/Conversations List/ConversationsViewModel.swift b/Convos/Conversations List/ConversationsViewModel.swift index 9c6c5cc24..e2be976b7 100644 --- a/Convos/Conversations List/ConversationsViewModel.swift +++ b/Convos/Conversations List/ConversationsViewModel.swift @@ -110,6 +110,32 @@ final class ConversationsViewModel { var presentingPinLimitInfo: Bool = false var conversations: [Conversation] = [] + var staleDeviceState: StaleDeviceState = .healthy + + /// True when any inbox is stale (partial or full). Drives banner visibility. + var isDeviceStale: Bool { + staleDeviceState.hasAnyStaleInboxes + } + + /// True when the device is fully stale (every used inbox revoked). + var isFullStale: Bool { + staleDeviceState == .fullStale + } + + /// User can still start/join conversations when there's at least one + /// healthy inbox (i.e., not in fullStale). + var canStartOrJoinConversations: Bool { + staleDeviceState.hasUsableInboxes + } + @ObservationIgnored + private var staleInboxIds: Set = [] + /// Source-of-truth list from `conversationsPublisher`, unfiltered. + /// We recompute `conversations` from this whenever staleInboxIds or + /// hiddenConversationIds change — filtering `self.conversations` + /// in-place would lose recovered conversations when an inbox goes + /// from stale back to healthy. + @ObservationIgnored + private var unfilteredConversations: [Conversation] = [] private var hiddenConversationIds: Set = [] private var conversationsCount: Int = 0 { didSet { @@ -191,6 +217,7 @@ final class ConversationsViewModel { // MARK: - Private let session: any SessionManagerProtocol + let databaseManager: (any DatabaseManagerProtocol)? private let conversationsRepository: any ConversationsRepositoryProtocol private let conversationsCountRepository: any ConversationsCountRepositoryProtocol @ObservationIgnored @@ -204,9 +231,11 @@ final class ConversationsViewModel { init( session: any SessionManagerProtocol, + databaseManager: (any DatabaseManagerProtocol)? = nil, horizontalSizeClass: UserInterfaceSizeClass? = nil ) { self.session = session + self.databaseManager = databaseManager self.horizontalSizeClass = horizontalSizeClass let coordinator = FocusCoordinator(horizontalSizeClass: horizontalSizeClass) self.focusCoordinator = coordinator @@ -219,13 +248,19 @@ final class ConversationsViewModel { kinds: .groups ) do { - self.conversations = try conversationsRepository.fetchAll() + let initial = try conversationsRepository.fetchAll() + // Seed both the visible list AND the unfiltered cache so the first + // staleInboxIdsPublisher emit in observe() doesn't recompute + // against an empty source and wipe the initial data. + self.unfilteredConversations = initial + self.conversations = initial self.conversationsCount = try conversationsCountRepository.fetchCount() if conversationsCount > 1 { hasCreatedMoreThanOneConvo = true } } catch { Log.error("Error fetching conversations: \(error)") + self.unfilteredConversations = [] self.conversations = [] self.conversationsCount = 0 } @@ -276,6 +311,7 @@ final class ConversationsViewModel { } func onStartConvo() { + guard canStartOrJoinConversations else { return } newConversationViewModel = NewConversationViewModel( session: session, mode: .newConversation @@ -283,6 +319,7 @@ final class ConversationsViewModel { } func onJoinConvo() { + guard canStartOrJoinConversations else { return } newConversationViewModel = NewConversationViewModel( session: session, mode: .scanner @@ -290,6 +327,7 @@ final class ConversationsViewModel { } private func join(from inviteCode: String) { + guard canStartOrJoinConversations else { return } newConversationViewModel = NewConversationViewModel( session: session, mode: .joinInvite(code: inviteCode) @@ -301,20 +339,94 @@ final class ConversationsViewModel { appSettingsViewModel.deleteAllData {} } - func leave(conversation: Conversation) { - if let index = conversations.firstIndex(of: conversation) { - conversations.remove(at: index) + // MARK: - Stale state handling + + /// Reacts to changes in the device's stale-installation state. + /// + /// Behavior per state: + /// - `healthy` → no action + /// - `partialStale` → cancel any in-flight new conversation flow that + /// may have been started before the partial state was detected; the + /// user can still create new conversations in remaining healthy inboxes + /// - `fullStale` → close any in-flight new conversation flow, dismiss + /// the selection, and trigger an automatic local reset (countdown + /// handled by the UI) + private func handleStaleStateTransition( + from previous: StaleDeviceState, + to current: StaleDeviceState + ) { + guard previous != current else { return } + + let event: String + switch (previous, current) { + case (.healthy, .partialStale): event = "healthy_to_partial" + case (.healthy, .fullStale): event = "healthy_to_full" + case (.partialStale, .fullStale): event = "partial_to_full" + case (.partialStale, .healthy): event = "partial_to_healthy" + case (.fullStale, .partialStale): event = "full_to_partial" + case (.fullStale, .healthy): event = "full_to_healthy" + default: event = "unknown_transition" + } + Log.info("[StaleDevice] state transition: \(event)") + + switch current { + case .healthy: + isPendingFullStaleAutoReset = false + case .partialStale: + // Continue allowing new convos in healthy inboxes — only close + // an in-flight flow if it can no longer complete. + isPendingFullStaleAutoReset = false + case .fullStale: + newConversationViewModel = nil + selectedConversation = nil + // Trigger the auto-reset countdown. The view binds to + // `isPendingFullStaleAutoReset` to render a cancellable countdown. + isPendingFullStaleAutoReset = true } + } + + /// True when the UI should be showing the auto-reset countdown for full stale. + /// The view layer renders a "Resetting in N seconds" countdown that the user + /// can cancel — `cancelFullStaleAutoReset()` clears this back to false. + var isPendingFullStaleAutoReset: Bool = false + + /// Recompute the visible `conversations` list from the unfiltered source, + /// applying current `staleInboxIds` and `hiddenConversationIds` filters. + /// Must be called whenever any of those three inputs change so that a + /// previously-filtered conversation can reappear when its inbox recovers. + private func recomputeVisibleConversations() { + let filtered = unfilteredConversations + .filter { !staleInboxIds.contains($0.inboxId) } + conversations = hiddenConversationIds.isEmpty + ? filtered + : filtered.filter { !hiddenConversationIds.contains($0.id) } + } + + func cancelFullStaleAutoReset() { + isPendingFullStaleAutoReset = false + Log.info("[StaleDevice] auto-reset cancelled by user") + } + + func confirmFullStaleAutoReset() { + Log.info("[StaleDevice] auto-reset confirmed") + isPendingFullStaleAutoReset = false + deleteAllData() + } + func leave(conversation: Conversation) { + hiddenConversationIds.insert(conversation.id) if selectedConversation == conversation { selectedConversation = nil } + recomputeVisibleConversations() Task { [weak self] in guard let self else { return } do { try await session.deleteInbox(clientId: conversation.clientId, inboxId: conversation.inboxId) } catch { + self.hiddenConversationIds.remove(conversation.id) + self.recomputeVisibleConversations() Log.error("Error leaving convo: \(error.localizedDescription)") } } @@ -329,6 +441,7 @@ final class ConversationsViewModel { Task { @MainActor [weak self] in guard let self else { return } Log.info("Left conversation notification received for conversation: \(conversationId)") + unfilteredConversations.removeAll { $0.id == conversationId } conversations.removeAll { $0.id == conversationId } if _selectedConversationId == conversationId { _selectedConversationId = nil @@ -365,17 +478,51 @@ final class ConversationsViewModel { self?.conversationsCount = conversationsCount } .store(in: &cancellables) + + let inboxesRepository = InboxesRepository(databaseReader: session.databaseReader) + inboxesRepository.staleDeviceStatePublisher() + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self else { return } + let previousState = self.staleDeviceState + self.staleDeviceState = state + self.handleStaleStateTransition(from: previousState, to: state) + } + .store(in: &cancellables) + + inboxesRepository.staleInboxIdsPublisher() + .receive(on: DispatchQueue.main) + .sink { [weak self] ids in + guard let self else { return } + self.staleInboxIds = ids + // Recompute from the unfiltered source list. Filtering + // `self.conversations` in-place would permanently drop + // conversations whose inbox later recovers from stale + // back to healthy — `conversationsPublisher` only emits + // on DB change, so it would not re-hydrate them. + self.recomputeVisibleConversations() + // Clear selection if the selected conversation belongs to a now-stale inbox. + // Dismissing an in-flight newConversationViewModel is handled by + // handleStaleStateTransition only on transition to fullStale — partial + // stale keeps any active compose flow so the user can finish composing + // in a still-healthy inbox. + if let selectedId = self._selectedConversationId, + !self.conversations.contains(where: { $0.id == selectedId }) { + self.selectedConversationId = nil + } + } + .store(in: &cancellables) + conversationsRepository.conversationsPublisher .receive(on: DispatchQueue.main) .sink { [weak self] conversations in guard let self else { return } - self.conversations = hiddenConversationIds.isEmpty - ? conversations - : conversations.filter { !hiddenConversationIds.contains($0.id) } + self.unfilteredConversations = conversations + self.recomputeVisibleConversations() - // Clear selection if selected conversation no longer exists + // Clear selection if selected conversation no longer exists in the filtered list if let selectedId = _selectedConversationId, - !conversations.contains(where: { $0.id == selectedId }) { + !self.conversations.contains(where: { $0.id == selectedId }) { selectedConversationId = nil } @@ -514,11 +661,15 @@ final class ConversationsViewModel { let inboxId = conversation.inboxId let memberInboxIds = conversation.members.map { $0.profile.inboxId } + // Optimistic hide: the conversation stays in unfilteredConversations + // (the publisher hasn't emitted yet) but we filter it out of the + // visible list via hiddenConversationIds so the user sees it disappear + // immediately. hiddenConversationIds.insert(conversationId) if selectedConversation == conversation { selectedConversation = nil } - conversations.removeAll { $0.id == conversationId } + recomputeVisibleConversations() Task { [weak self] in guard let self else { return } @@ -541,10 +692,22 @@ final class ConversationsViewModel { userInfo: ["conversationId": conversationId] ) conversation.postLeftConversationNotification() + + // On success, drop from unfilteredConversations too so the + // visible list stays correct until the conversationsPublisher + // catches up with the DB delete. Then clear the hide marker. + // Must remove from unfilteredConversations BEFORE removing + // from hiddenConversationIds — otherwise recompute would + // briefly resurface the conversation. + self.unfilteredConversations.removeAll { $0.id == conversationId } self.hiddenConversationIds.remove(conversationId) + self.recomputeVisibleConversations() Log.info("Exploded conversation from list: \(conversationId)") } catch { + // On failure, bring the conversation back by clearing the + // hide marker. unfilteredConversations still contains it. self.hiddenConversationIds.remove(conversationId) + self.recomputeVisibleConversations() Log.error("Error exploding conversation from list: \(error.localizedDescription)") } } diff --git a/Convos/Conversations List/StaleDeviceBanner.swift b/Convos/Conversations List/StaleDeviceBanner.swift new file mode 100644 index 000000000..b50d7b954 --- /dev/null +++ b/Convos/Conversations List/StaleDeviceBanner.swift @@ -0,0 +1,198 @@ +import ConvosCore +import SwiftUI + +/// Top-of-list banner shown when at least one inbox is stale (revoked). +/// +/// Two variants: +/// - **Partial stale**: some inboxes still work. Banner says "Some conversations +/// moved to another device." Action: "Reset device" (destructive verb makes the +/// intent explicit so users don't tap "Continue" expecting to keep their data). +/// - **Full stale**: every inbox revoked. Banner says "This device has been replaced." +/// Action is the same reset, but the auto-reset countdown will fire shortly. +struct StaleDeviceBanner: View { + let state: StaleDeviceState + let onResetDevice: () -> Void + let onLearnMore: () -> Void + + private var title: String { + switch state { + case .partialStale: "Some conversations moved to another device" + case .fullStale: "This device has been replaced" + case .healthy: "" + } + } + + private var body_: String { + switch state { + case .partialStale: "Some inboxes have been restored on another device. Resetting will clear local data on this device and restart setup." + case .fullStale: "Your account has been restored on another device. Resetting will clear local data on this device and restart setup." + case .healthy: "" + } + } + + var body: some View { + let resetAction = onResetDevice + let learnMoreAction = onLearnMore + VStack(spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.colorLava) + .font(.callout) + Text(title) + .font(.callout) + .fontWeight(.semibold) + .foregroundStyle(.colorTextPrimary) + .multilineTextAlignment(.center) + } + Text(body_) + .font(.system(size: 13)) + .foregroundStyle(.colorTextSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + Button(action: learnMoreAction) { + Text("Learn more") + .font(.system(size: 14, weight: .medium)) + } + .buttonStyle(.bordered) + + Button(role: .destructive, action: resetAction) { + Text("Reset device") + .font(.system(size: 14, weight: .medium)) + } + .buttonStyle(.bordered) + .tint(.colorLava) + } + } + .frame(maxWidth: .infinity) + .padding(16) + .background(Color.colorFillMinimal, in: RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal, 16) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(title). \(body_). Learn more or reset device.") + .accessibilityIdentifier("stale-device-banner") + } +} + +struct StaleDeviceInfoView: View { + let state: StaleDeviceState + let onResetDevice: () -> Void + + @Environment(\.dismiss) private var dismiss: DismissAction + + private var title: String { + switch state { + case .partialStale: "Some conversations moved away" + case .fullStale: "This device no longer has access" + case .healthy: "" + } + } + + private var paragraphs: [FeatureInfoParagraph] { + switch state { + case .partialStale: + return [ + .init("Some of your inboxes were restored on another device. That device revoked this installation, so those conversations are no longer accessible here."), + .init("You can keep using the inboxes that are still active, or reset this device to start fresh.") + ] + case .fullStale: + return [ + .init("Your account was restored on another device, which revoked this installation. You can no longer send or receive messages on this device."), + .init("Reset to clear local data and start setup again.") + ] + case .healthy: + return [] + } + } + + var body: some View { + let action = { + dismiss() + onResetDevice() + } + FeatureInfoSheet( + title: title, + paragraphs: paragraphs, + primaryButtonTitle: "Reset device", + primaryButtonAction: action, + showDragIndicator: true + ) + } +} + +/// Modal countdown shown when entering full-stale state. +/// +/// Auto-fires the reset after `countdownSeconds`. The user can cancel with the +/// secondary action — useful if detection is wrong (e.g. the user knows they +/// just restored on the same device and the check is racing) or if they want +/// to take a screenshot / preserve diagnostics first. +struct FullStaleAutoResetCountdown: View { + let onReset: () -> Void + let onCancel: () -> Void + var countdownSeconds: Int = 5 + + @State private var remaining: Int = 5 + + var body: some View { + let resetAction = onReset + let cancelAction = onCancel + VStack(spacing: 16) { + Image(systemName: "exclamationmark.octagon.fill") + .foregroundStyle(.colorLava) + .font(.largeTitle) + + Text("Resetting this device") + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(.colorTextPrimary) + .multilineTextAlignment(.center) + + Text("Your account was restored on another device. This one can no longer access your conversations.\n\nResetting in \(remaining) second\(remaining == 1 ? "" : "s")…") + .font(.subheadline) + .foregroundStyle(.colorTextSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 12) { + Button(action: cancelAction) { + Text("Cancel") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button(role: .destructive, action: resetAction) { + Text("Reset now") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.colorLava) + } + } + .padding(24) + .background(Color.colorBackgroundSurfaceless, in: RoundedRectangle(cornerRadius: 20)) + .padding(.horizontal, 24) + .task { + remaining = countdownSeconds + for _ in 0.. com.apple.developer.devicecheck.appattest-environment production + com.apple.developer.icloud-container-identifiers + + $(ICLOUD_CONTAINER_IDENTIFIER) + + com.apple.developer.ubiquity-container-identifiers + + $(ICLOUD_CONTAINER_IDENTIFIER) + com.apple.security.application-groups $(APP_GROUP_IDENTIFIER) diff --git a/Convos/ConvosApp.swift b/Convos/ConvosApp.swift index 0cc88bfe3..76473e25d 100644 --- a/Convos/ConvosApp.swift +++ b/Convos/ConvosApp.swift @@ -58,7 +58,7 @@ struct ConvosApp: App { await agentKeyset.prefetch() try? await AgentVerificationWriter.reverifyUnverifiedAgents(in: dbWriter) } - self.conversationsViewModel = .init(session: convos.session) + self.conversationsViewModel = .init(session: convos.session, databaseManager: convos.databaseManager) appDelegate.session = convos.session appDelegate.pushNotificationRegistrar = convos.platformProviders.pushNotificationRegistrar } diff --git a/Convos/Debug View/BackupDebugView.swift b/Convos/Debug View/BackupDebugView.swift new file mode 100644 index 000000000..76d021860 --- /dev/null +++ b/Convos/Debug View/BackupDebugView.swift @@ -0,0 +1,394 @@ +import ConvosCore +import SwiftUI + +struct BackupDebugView: View { + let environment: AppEnvironment + let session: any SessionManagerProtocol + var databaseManager: (any DatabaseManagerProtocol)? + + @State private var isPerformingAction: Bool = false + @State private var activeAction: String? + @State private var actionResultMessage: String? + @State private var showingActionResult: Bool = false + @State private var lastBackupMetadata: BackupBundleMetadata? + @State private var availableBackup: (url: URL, metadata: BackupBundleMetadata)? + @State private var isLoading: Bool = true + @State private var backupDirectoryPath: String? + @State private var iCloudAvailable: Bool = false + @State private var showingRestoreConfirmation: Bool = false + + var body: some View { + List { + statusSection + if activeAction != "Restore from backup" { + actionsSection + } + restoreSection + } + .navigationTitle("Backup") + .toolbarTitleDisplayMode(.inline) + .task { + await refreshStatus() + } + .alert("Backup", isPresented: $showingActionResult, presenting: actionResultMessage) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } + .confirmationDialog( + "Restore from backup?", + isPresented: $showingRestoreConfirmation, + titleVisibility: .visible + ) { + let confirmAction = { restoreFromBackupAction() } + Button("Restore", role: .destructive, action: confirmAction) + Button("Cancel", role: .cancel) {} + } message: { + if let backup = availableBackup { + let date = backup.metadata.createdAt.formatted(date: .abbreviated, time: .shortened) + Text("This will replace all current conversations and data with the backup from \(backup.metadata.deviceName) (\(date)).") + } + } + } + + private var statusSection: some View { + Section("Status") { + if isLoading { + HStack { + Text("Loading status…") + Spacer() + ProgressView() + } + } else { + statusRow( + title: "iCloud container", + value: iCloudAvailable ? "Available" : "Unavailable" + ) + + if let metadata = lastBackupMetadata { + statusRow( + title: "Last backup", + value: metadata.createdAt.formatted(date: .abbreviated, time: .shortened) + ) + statusRow(title: "Device", value: metadata.deviceName) + statusRow(title: "Inbox count", value: "\(metadata.inboxCount)") + statusRow(title: "Bundle version", value: "\(metadata.version)") + } else { + statusRow(title: "Last backup", value: "None") + } + + if let path = backupDirectoryPath { + VStack(alignment: .leading, spacing: DesignConstants.Spacing.stepX) { + Text("Backup path") + Text(path) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.colorTextSecondary) + } + } + } + } + } + + private var actionsSection: some View { + Section { + let refreshAction = { refreshStatusButtonAction() } + Button(action: refreshAction) { + actionLabel("Refresh status") + } + .disabled(isPerformingAction) + + let backupAction = { createBackupAction() } + Button(action: backupAction) { + actionLabel("Create backup now") + } + .accessibilityIdentifier("backup-debug-create-button") + .disabled(isPerformingAction) + + let revokeAction = { revokeVaultInstallationsAction() } + Button(role: .destructive, action: revokeAction) { + actionLabel("Revoke all other vault installations") + } + .accessibilityIdentifier("backup-debug-revoke-vault-button") + .disabled(isPerformingAction) + } header: { + Text("Actions") + } footer: { + Text("Use 'Revoke' to clear extra vault installations accumulated during testing (max 10 per inboxId).") + } + } + + @ViewBuilder + private var restoreSection: some View { + if !isLoading, databaseManager != nil { + Section { + if let backup = availableBackup { + statusRow( + title: "Available backup", + value: backup.metadata.createdAt.formatted(date: .abbreviated, time: .shortened) + ) + statusRow(title: "From device", value: backup.metadata.deviceName) + statusRow(title: "Conversations", value: "\(backup.metadata.inboxCount)") + + let promptAction = { showingRestoreConfirmation = true } + Button(role: .destructive, action: promptAction) { + actionLabel("Restore from backup") + } + .accessibilityIdentifier("backup-debug-restore-button") + .disabled(isPerformingAction) + } else { + statusRow(title: "Available backup", value: "None found") + } + } header: { + Text("Restore") + } footer: { + Text("Restoring will stop all sessions, replace the database, and import conversation archives. This is destructive and cannot be undone.") + } + } + } + + @ViewBuilder + private func actionLabel(_ title: String) -> some View { + HStack { + Text(title) + Spacer() + if activeAction == title { + ProgressView() + .scaleEffect(0.85) + } + } + .foregroundStyle(.colorTextPrimary) + } + + @ViewBuilder + private func statusRow(title: String, value: String) -> some View { + HStack { + Text(title) + Spacer() + Text(value) + .font(.footnote) + .foregroundStyle(.colorTextSecondary) + .multilineTextAlignment(.trailing) + } + } + + private func refreshStatusButtonAction() { + runAction(title: "Refresh status") { + await refreshStatus() + return "Refreshed backup status." + } + } + + private func restoreFromBackupAction() { + guard let backup = availableBackup else { return } + let restoreManager: RestoreManager + do { + restoreManager = try makeRestoreManager() + } catch { + actionResultMessage = "Restore failed: \(error.localizedDescription)" + showingActionResult = true + return + } + runAction(title: "Restore from backup") { + try await restoreManager.restoreFromBackup(bundleURL: backup.url) + let state = await restoreManager.state + if case .completed(let inboxCount, let failedKeyCount) = state { + var message = "Restore completed: \(inboxCount) conversation(s) restored." + if failedKeyCount > 0 { + message += "\n\(failedKeyCount) key(s) failed to restore." + } + return message + } + return "Restore completed." + } + } + + private func revokeVaultInstallationsAction() { + let vaultKeyStore = makeVaultKeyStore() + let vaultManager = session.vaultService as? VaultManager + runAction(title: "Revoke all other vault installations") { [environment] in + let vaultIdentity = try await vaultKeyStore.loadAny() + let keepId: String? = await vaultManager?.vaultInstallationId + let count = try await XMTPInstallationRevoker.revokeOtherInstallations( + inboxId: vaultIdentity.inboxId, + signingKey: vaultIdentity.keys.signingKey, + keepInstallationId: keepId, + environment: environment + ) + return "Revoked \(count) vault installation(s)." + } + } + + private func createBackupAction() { + let backupManager: BackupManager + do { + backupManager = try makeBackupManager() + } catch { + actionResultMessage = "Create backup failed: \(error.localizedDescription)" + showingActionResult = true + return + } + runAction(title: "Create backup") { + let outputURL = try await backupManager.createBackup() + await refreshStatus() + let timestamp = Date().formatted(date: .abbreviated, time: .shortened) + return "Backup created at \(outputURL.lastPathComponent).\n\(timestamp)" + } + } + + private func runAction(title: String, operation: @escaping @Sendable () async throws -> String) { + guard !isPerformingAction else { return } + isPerformingAction = true + activeAction = title + + Task { + let message: String + do { + message = try await operation() + } catch { + message = "\(title) failed: \(error.localizedDescription)" + } + + await MainActor.run { + actionResultMessage = message + showingActionResult = true + isPerformingAction = false + activeAction = nil + } + } + } + + private func refreshStatus() async { + await MainActor.run { isLoading = true } + + let cloudAvailable = isICloudAvailable() + let backupDir = resolveBackupDirectory() + let metadata: BackupBundleMetadata? = if let backupDir { + try? BackupBundleMetadata.read(from: backupDir) + } else { + nil + } + let backup = RestoreManager.findAvailableBackup(environment: environment) + + await MainActor.run { + iCloudAvailable = cloudAvailable + backupDirectoryPath = backupDir?.path + lastBackupMetadata = metadata + availableBackup = backup + isLoading = false + } + } + + private func isICloudAvailable() -> Bool { + FileManager.default.url(forUbiquityContainerIdentifier: environment.iCloudContainerIdentifier) != nil + } + + private func resolveBackupDirectory() -> URL? { + let deviceId = DeviceInfo.deviceIdentifier + let containerId = environment.iCloudContainerIdentifier + + if let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: containerId) { + let dir = containerURL + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent("backups", isDirectory: true) + .appendingPathComponent(deviceId, isDirectory: true) + if BackupBundleMetadata.exists(in: dir) { return dir } + } + + let localDir = environment.defaultDatabasesDirectoryURL + .appendingPathComponent("backups", isDirectory: true) + .appendingPathComponent(deviceId, isDirectory: true) + if BackupBundleMetadata.exists(in: localDir) { return localDir } + + return nil + } + + private func makeRestoreManager() throws -> RestoreManager { + guard let databaseManager else { + throw BackupDebugError.databaseManagerUnavailable + } + + let accessGroup = environment.keychainAccessGroup + let identityStore = KeychainIdentityStore(accessGroup: accessGroup) + let vaultKeyStore = makeVaultKeyStore() + let archiveImporter = ConvosRestoreArchiveImporter( + identityStore: identityStore, + environment: environment + ) + let vaultManager = session.vaultService as? VaultManager + + let capturedEnvironment = environment + let revoker: RestoreInstallationRevoker = { inboxId, signingKey, keepId in + try await XMTPInstallationRevoker.revokeOtherInstallations( + inboxId: inboxId, + signingKey: signingKey, + keepInstallationId: keepId, + environment: capturedEnvironment + ) + } + + return RestoreManager( + vaultKeyStore: vaultKeyStore, + identityStore: identityStore, + databaseManager: databaseManager, + archiveImporter: archiveImporter, + restoreLifecycleController: session as? any RestoreLifecycleControlling, + vaultManager: vaultManager, + installationRevoker: revoker, + environment: environment + ) + } + + private func makeBackupManager() throws -> BackupManager { + guard let vaultManager = session.vaultService as? VaultManager else { + throw BackupDebugError.vaultUnavailable + } + + let accessGroup = environment.keychainAccessGroup + let identityStore = KeychainIdentityStore(accessGroup: accessGroup) + let vaultKeyStore = makeVaultKeyStore() + let archiveProvider = ConvosBackupArchiveProvider( + vaultService: vaultManager, + identityStore: identityStore, + environment: environment + ) + + return BackupManager( + vaultKeyStore: vaultKeyStore, + archiveProvider: archiveProvider, + identityStore: identityStore, + databaseReader: session.databaseReader, + environment: environment + ) + } + + private func makeVaultKeyStore() -> VaultKeyStore { + let accessGroup = environment.keychainAccessGroup + let localStore = KeychainIdentityStore( + accessGroup: accessGroup, + service: "org.convos.vault-identity", + accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ) + let iCloudStore = KeychainIdentityStore( + accessGroup: accessGroup, + service: "org.convos.vault-identity.icloud", + accessibility: kSecAttrAccessibleAfterFirstUnlock, + synchronizable: true + ) + let dualStore = ICloudIdentityStore(localStore: localStore, icloudStore: iCloudStore) + return VaultKeyStore(store: dualStore) + } + + private enum BackupDebugError: LocalizedError { + case vaultUnavailable + case databaseManagerUnavailable + + var errorDescription: String? { + switch self { + case .vaultUnavailable: + return "Vault service is not available" + case .databaseManagerUnavailable: + return "Database manager is not available" + } + } + } +} diff --git a/Convos/Debug View/DebugExportView.swift b/Convos/Debug View/DebugExportView.swift index 6d73d19db..1f91bcc74 100644 --- a/Convos/Debug View/DebugExportView.swift +++ b/Convos/Debug View/DebugExportView.swift @@ -4,10 +4,11 @@ import SwiftUI struct DebugExportView: View { let environment: AppEnvironment let session: any SessionManagerProtocol + var databaseManager: (any DatabaseManagerProtocol)? var body: some View { List { - DebugViewSection(environment: environment, session: session) + DebugViewSection(environment: environment, session: session, databaseManager: databaseManager) } .scrollContentBackground(.hidden) .background(.colorBackgroundRaisedSecondary) diff --git a/Convos/Debug View/DebugView.swift b/Convos/Debug View/DebugView.swift index 7a337e98f..7e7deb208 100644 --- a/Convos/Debug View/DebugView.swift +++ b/Convos/Debug View/DebugView.swift @@ -32,6 +32,7 @@ import UIKit struct DebugViewSection: View { let environment: AppEnvironment let session: any SessionManagerProtocol + var databaseManager: (any DatabaseManagerProtocol)? @State private var notificationAuthStatus: UNAuthorizationStatus = .notDetermined @State private var notificationAuthGranted: Bool = false @State private var lastDeviceToken: String = "" @@ -154,6 +155,16 @@ struct DebugViewSection: View { .foregroundStyle(.colorTextPrimary) } .accessibilityIdentifier("vault-key-sync-debug-row") + + if let databaseManager { + NavigationLink { + BackupDebugView(environment: environment, session: session, databaseManager: databaseManager) + } label: { + Text("Backup") + .foregroundStyle(.colorTextPrimary) + } + .accessibilityIdentifier("backup-debug-row") + } } Section("Sentry Testing") { diff --git a/Convos/Debug View/VaultKeySyncDebugView.swift b/Convos/Debug View/VaultKeySyncDebugView.swift index 091d86b2b..74ec2dc5f 100644 --- a/Convos/Debug View/VaultKeySyncDebugView.swift +++ b/Convos/Debug View/VaultKeySyncDebugView.swift @@ -16,6 +16,8 @@ struct VaultKeySyncDebugView: View { var body: some View { List { statusSection + keysDetailSection + backupFilesSection actionsSection } .navigationTitle("Vault Key Sync") @@ -79,6 +81,108 @@ struct VaultKeySyncDebugView: View { } } + @ViewBuilder + private var keysDetailSection: some View { + if !isLoading { + Section("Vault Keys") { + if snapshot.vaultKeys.isEmpty { + Text("No vault keys found") + .foregroundStyle(.colorTextSecondary) + } else { + ForEach(snapshot.vaultKeys) { key in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + if key.inboxId == snapshot.vaultInboxId { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.caption) + } + Text(key.inboxId) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + } + HStack(spacing: 8) { + Text("client: \(key.clientId)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.colorTextSecondary) + .lineLimit(1) + .truncationMode(.middle) + } + HStack(spacing: 8) { + Label( + key.isLocal ? "Local" : "No local", + systemImage: key.isLocal ? "checkmark.circle" : "xmark.circle" + ) + .foregroundStyle(key.isLocal ? .green : .red) + Label( + key.isICloud ? "iCloud" : "No iCloud", + systemImage: key.isICloud ? "checkmark.circle" : "xmark.circle" + ) + .foregroundStyle(key.isICloud ? .green : .red) + } + .font(.caption2) + HStack(spacing: 12) { + if key.isLocal { + let deleteLocalAction = { deleteLocalKey(inboxId: key.inboxId) } + Button("Delete local", role: .destructive, action: deleteLocalAction) + .font(.caption) + } + if key.isICloud { + let deleteICloudAction = { deleteICloudKey(inboxId: key.inboxId) } + Button("Delete iCloud", role: .destructive, action: deleteICloudAction) + .font(.caption) + } + if key.isICloud && !key.isLocal { + let adoptAction = { adoptICloudKey(inboxId: key.inboxId) } + Button("Adopt locally", action: adoptAction) + .font(.caption) + } + } + .disabled(isPerformingAction) + } + .padding(.vertical, 2) + } + } + } + } + } + + @ViewBuilder + private var backupFilesSection: some View { + if !isLoading { + Section("iCloud Backup Files") { + if snapshot.backupFiles.isEmpty { + Text("No backups found in iCloud container") + .foregroundStyle(.colorTextSecondary) + } else { + ForEach(snapshot.backupFiles) { file in + VStack(alignment: .leading, spacing: 4) { + Text(file.deviceName) + .font(.subheadline) + .fontWeight(.medium) + HStack(spacing: 12) { + Label(file.metadataCreatedAt, systemImage: "clock") + Label(file.size, systemImage: "doc") + } + .font(.caption) + .foregroundStyle(.colorTextSecondary) + HStack(spacing: 12) { + Label("\(file.inboxCount) inbox(es)", systemImage: "person.2") + Text(file.path) + .lineLimit(1) + .truncationMode(.middle) + } + .font(.caption2) + .foregroundStyle(.colorTextTertiary) + } + .padding(.vertical, 2) + } + } + } + } + } + private var actionsSection: some View { Section { Button(action: refreshStatusAction) { @@ -159,6 +263,33 @@ struct VaultKeySyncDebugView: View { } } + private func deleteLocalKey(inboxId: String) { + let store = makeLocalVaultStore() + runAction(title: "Delete local key") { + try await store.delete(inboxId: inboxId) + await refreshStatus() + return "Deleted local key: \(inboxId)" + } + } + + private func deleteICloudKey(inboxId: String) { + let store = makeICloudVaultStore() + runAction(title: "Delete iCloud key") { + try await store.delete(inboxId: inboxId) + await refreshStatus() + return "Deleted iCloud key: \(inboxId)" + } + } + + private func adoptICloudKey(inboxId: String) { + let dualStore = ICloudIdentityStore(localStore: makeLocalVaultStore(), icloudStore: makeICloudVaultStore()) + runAction(title: "Adopt iCloud key") { + _ = try await dualStore.identity(for: inboxId) + await refreshStatus() + return "Adopted iCloud key locally: \(inboxId)" + } + } + private func refreshStatusAction() { runAction(title: "Refresh status") { await refreshStatus() @@ -249,24 +380,74 @@ struct VaultKeySyncDebugView: View { let iCloudStore = makeICloudVaultStore() let dualStore = ICloudIdentityStore(localStore: localStore, icloudStore: iCloudStore) - let localVaultKeyCount = (try? await localStore.loadAll().count) ?? 0 - let iCloudVaultKeyCount = (try? await iCloudStore.loadAll().count) ?? 0 + let localIdentities = (try? await localStore.loadAll()) ?? [] + let iCloudIdentities = (try? await iCloudStore.loadAll()) ?? [] let hasICloudOnlyKeys = await dualStore.hasICloudOnlyKeys() let bootstrapInfo = await loadVaultBootstrapInfo() + let localInboxIds = Set(localIdentities.map(\.inboxId)) + let iCloudInboxIds = Set(iCloudIdentities.map(\.inboxId)) + let allInboxIds = localInboxIds.union(iCloudInboxIds) + + let vaultKeys: [VaultKeyInfo] = allInboxIds.sorted().map { inboxId in + let identity = localIdentities.first(where: { $0.inboxId == inboxId }) + ?? iCloudIdentities.first(where: { $0.inboxId == inboxId }) + return VaultKeyInfo( + inboxId: inboxId, + clientId: identity?.clientId ?? "unknown", + isLocal: localInboxIds.contains(inboxId), + isICloud: iCloudInboxIds.contains(inboxId) + ) + } + + let backupFiles = loadBackupFiles() + return Snapshot( isICloudAccountAvailable: ICloudIdentityStore.isICloudAccountAvailable, bootstrapState: bootstrapInfo.state, bootstrapErrorMessage: bootstrapInfo.errorMessage, vaultInboxId: bootstrapInfo.vaultInboxId, - localVaultKeyCount: localVaultKeyCount, - iCloudVaultKeyCount: iCloudVaultKeyCount, + localVaultKeyCount: localIdentities.count, + iCloudVaultKeyCount: iCloudIdentities.count, hasICloudOnlyKeys: hasICloudOnlyKeys, - lastRefreshed: Date() + lastRefreshed: Date(), + vaultKeys: vaultKeys, + backupFiles: backupFiles ) } + private func loadBackupFiles() -> [BackupFileInfo] { + let containerId = environment.iCloudContainerIdentifier + guard let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: containerId) else { + return [] + } + let backupsDir = containerURL + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent("backups", isDirectory: true) + + guard let deviceDirs = try? FileManager.default.contentsOfDirectory( + at: backupsDir, + includingPropertiesForKeys: nil + ) else { + return [] + } + + return deviceDirs.compactMap { deviceDir in + guard let metadata = try? BackupBundleMetadata.read(from: deviceDir) else { return nil } + let bundlePath = deviceDir.appendingPathComponent("backup-latest.encrypted") + let size = (try? FileManager.default.attributesOfItem(atPath: bundlePath.path)[.size] as? Int) ?? 0 + let sizeStr = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) + return BackupFileInfo( + deviceName: metadata.deviceName, + path: deviceDir.lastPathComponent, + size: sizeStr, + metadataCreatedAt: metadata.createdAt.formatted(date: .abbreviated, time: .shortened), + inboxCount: metadata.inboxCount + ) + } + } + private func loadVaultBootstrapInfo() async -> VaultBootstrapInfo { guard let vaultManager = session.vaultService as? VaultManager else { return .init(state: "Unavailable", errorMessage: nil, vaultInboxId: nil) @@ -338,12 +519,30 @@ struct VaultKeySyncDebugView: View { KeychainIdentityStore( accessGroup: environment.keychainAccessGroup, service: Constant.vaultICloudIdentityService, - accessibility: kSecAttrAccessibleAfterFirstUnlock + accessibility: kSecAttrAccessibleAfterFirstUnlock, + synchronizable: true ) } } private extension VaultKeySyncDebugView { + struct VaultKeyInfo: Identifiable { + let inboxId: String + let clientId: String + let isLocal: Bool + let isICloud: Bool + var id: String { inboxId } + } + + struct BackupFileInfo: Identifiable { + let deviceName: String + let path: String + let size: String + let metadataCreatedAt: String + let inboxCount: Int + var id: String { path } + } + struct Snapshot { let isICloudAccountAvailable: Bool let bootstrapState: String @@ -353,6 +552,8 @@ private extension VaultKeySyncDebugView { let iCloudVaultKeyCount: Int let hasICloudOnlyKeys: Bool let lastRefreshed: Date + let vaultKeys: [VaultKeyInfo] + let backupFiles: [BackupFileInfo] static let empty: Snapshot = Snapshot( isICloudAccountAvailable: false, @@ -362,7 +563,9 @@ private extension VaultKeySyncDebugView { localVaultKeyCount: 0, iCloudVaultKeyCount: 0, hasICloudOnlyKeys: false, - lastRefreshed: .distantPast + lastRefreshed: .distantPast, + vaultKeys: [], + backupFiles: [] ) } diff --git a/Convos/Devices/JoinerPairingSheetViewModel.swift b/Convos/Devices/JoinerPairingSheetViewModel.swift index 5a27df2b1..7fa51ed10 100644 --- a/Convos/Devices/JoinerPairingSheetViewModel.swift +++ b/Convos/Devices/JoinerPairingSheetViewModel.swift @@ -1,5 +1,6 @@ import ConvosCore import CryptoKit +@preconcurrency import Foundation import Observation import SwiftUI @@ -28,9 +29,7 @@ final class JoinerPairingSheetViewModel { private let vaultManager: VaultManager? private let initiatorName: String? private var countdownTask: Task? - private nonisolated(unsafe) var keyBundleObserver: (any NSObjectProtocol)? - private nonisolated(unsafe) var pairingErrorObserver: (any NSObjectProtocol)? - private nonisolated(unsafe) var pinReceivedObserver: (any NSObjectProtocol)? + @ObservationIgnored private var notificationObservers: [any NSObjectProtocol] = [] private var initiatorInboxId: String? init( @@ -52,19 +51,14 @@ final class JoinerPairingSheetViewModel { } deinit { - if let keyBundleObserver { - NotificationCenter.default.removeObserver(keyBundleObserver) - } - if let pairingErrorObserver { - NotificationCenter.default.removeObserver(pairingErrorObserver) - } - if let pinReceivedObserver { - NotificationCenter.default.removeObserver(pinReceivedObserver) + let observers = notificationObservers + for observer in observers { + NotificationCenter.default.removeObserver(observer) } } private func observeNotifications() { - keyBundleObserver = NotificationCenter.default.addObserver( + notificationObservers.append(NotificationCenter.default.addObserver( forName: .vaultDidReceiveKeyBundle, object: nil, queue: .main @@ -73,9 +67,9 @@ final class JoinerPairingSheetViewModel { Task { @MainActor in self.onPairingCompleted() } - } + }) - pairingErrorObserver = NotificationCenter.default.addObserver( + notificationObservers.append(NotificationCenter.default.addObserver( forName: .vaultPairingError, object: nil, queue: .main @@ -85,9 +79,9 @@ final class JoinerPairingSheetViewModel { Task { @MainActor in self.onPairingFailed(message) } - } + }) - pinReceivedObserver = NotificationCenter.default.addObserver( + notificationObservers.append(NotificationCenter.default.addObserver( forName: .vaultDidReceivePin, object: nil, queue: .main @@ -99,7 +93,7 @@ final class JoinerPairingSheetViewModel { Task { @MainActor in self.onPinReceived(pin, from: senderInboxId) } - } + }) } var initiatorDeviceName: String { diff --git a/ConvosCore/Sources/ConvosCore/AppEnvironment.swift b/ConvosCore/Sources/ConvosCore/AppEnvironment.swift index 34facb260..8b6882a73 100644 --- a/ConvosCore/Sources/ConvosCore/AppEnvironment.swift +++ b/ConvosCore/Sources/ConvosCore/AppEnvironment.swift @@ -109,6 +109,16 @@ public enum AppEnvironment: Sendable { } } + public var iCloudContainerIdentifier: String { + switch self { + case .local(config: let config), .dev(config: let config), .production(config: let config): + let bundleId = config.appGroupIdentifier.replacingOccurrences(of: "group.", with: "") + return "iCloud.\(bundleId)" + case .tests: + return "iCloud.org.convos.ios-local" + } + } + public var keychainAccessGroup: String { // Use the app group identifier with team prefix for keychain sharing // This matches $(AppIdentifierPrefix)$(APP_GROUP_IDENTIFIER) in entitlements @@ -226,7 +236,7 @@ public extension AppEnvironment { return groupUrl.appendingPathComponent("logs", isDirectory: true) } - var defaultDatabasesDirectoryURL: URL { + public var defaultDatabasesDirectoryURL: URL { guard !isTestingEnvironment else { return FileManager.default.temporaryDirectory } diff --git a/ConvosCore/Sources/ConvosCore/Auth/Keychain/ICloudIdentityStore.swift b/ConvosCore/Sources/ConvosCore/Auth/Keychain/ICloudIdentityStore.swift index 1e32b82f5..f83529789 100644 --- a/ConvosCore/Sources/ConvosCore/Auth/Keychain/ICloudIdentityStore.swift +++ b/ConvosCore/Sources/ConvosCore/Auth/Keychain/ICloudIdentityStore.swift @@ -7,8 +7,10 @@ import Security /// - save: writes to both (iCloud failure is non-fatal) /// - identity(for:): reads local first, falls back to iCloud (and caches locally) /// - delete: removes from both +/// - deleteLocalOnly: removes only the local copy while preserving iCloud /// -/// The local copy is never deleted by this coordinator. It is the safety net. +/// The local copy is the normal safety net. Restore flows can explicitly remove +/// only the local copy while keeping the iCloud copy available for disaster recovery. /// Items written to the iCloud store with `AfterFirstUnlock` accessibility are /// automatically synced by iOS when iCloud Keychain is enabled at the system level. /// When iCloud Keychain is disabled, items remain local; when re-enabled, they sync. @@ -89,6 +91,12 @@ public actor ICloudIdentityStore: KeychainIdentityStoreProtocol { } } + /// Deletes only the local copy, preserving the iCloud copy. + /// The iCloud copy serves as the backup decryption key and must survive local data wipes. + public func deleteLocalOnly(inboxId: String) async throws { + try await localStore.delete(inboxId: inboxId) + } + public func delete(clientId: String) async throws -> KeychainIdentity { if let identity = try? await localStore.delete(clientId: clientId) { do { diff --git a/ConvosCore/Sources/ConvosCore/Auth/Keychain/KeychainIdentityStore.swift b/ConvosCore/Sources/ConvosCore/Auth/Keychain/KeychainIdentityStore.swift index ae5fcae9b..607871722 100644 --- a/ConvosCore/Sources/ConvosCore/Auth/Keychain/KeychainIdentityStore.swift +++ b/ConvosCore/Sources/ConvosCore/Auth/Keychain/KeychainIdentityStore.swift @@ -142,6 +142,7 @@ private struct KeychainQuery { let service: String let accessGroup: String let accessible: CFString + let synchronizable: Bool let clientId: String? init( @@ -149,12 +150,14 @@ private struct KeychainQuery { service: String, accessGroup: String, accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + synchronizable: Bool = false, clientId: String? = nil ) { self.account = account self.service = service self.accessGroup = accessGroup self.accessible = accessible + self.synchronizable = synchronizable self.clientId = clientId } @@ -164,7 +167,8 @@ private struct KeychainQuery { kSecAttrAccount as String: account, kSecAttrService as String: service, kSecAttrAccessGroup as String: accessGroup, - kSecAttrAccessible as String: accessible + kSecAttrAccessible as String: accessible, + kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue as Any : kCFBooleanFalse as Any ] if let clientId = clientId, let clientIdData = clientId.data(using: .utf8) { @@ -215,17 +219,22 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol { public static let defaultService: String = "org.convos.ios.KeychainIdentityStore.v2" public static let icloudService: String = "org.convos.ios.KeychainIdentityStore.v2.icloud" private static let localFormatMigrationKeyPrefix: String = "KeychainIdentityStore.localFormatMigrationComplete" + private static let synchronizableMigrationKeyPrefix: String = "KeychainIdentityStore.synchronizableMigrationComplete" // MARK: - Initialization + private let synchronizable: Bool + public init( accessGroup: String, service: String = KeychainIdentityStore.defaultService, - accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + synchronizable: Bool = false ) { self.keychainAccessGroup = accessGroup self.keychainService = service self.accessibility = accessibility + self.synchronizable = synchronizable } /// Migrates existing keychain items from `SecAccessControl` to plain `kSecAttrAccessible`. @@ -424,6 +433,115 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol { Log.info("Keychain format migration: completed, migrated \(migratedCount)/\(items.count) item(s)") } + /// Migrates existing keychain items to be synchronizable via iCloud Keychain. + /// + /// Items saved before the `synchronizable` flag was introduced default to non-synchronizable. + /// Queries with `synchronizable: true` will not find those items, so existing users would + /// lose access to their vault keys after enabling iCloud sync. This migration finds any + /// non-synchronizable items in the given service and re-saves them with + /// `kSecAttrSynchronizable: true` so they become visible to the new synchronizable store + /// and begin syncing to iCloud Keychain. + /// + /// Safe to call on every app launch — tracked per-service in UserDefaults. + public nonisolated static func migrateToSynchronizableIfNeeded( + accessGroup: String, + service: String, + accessibility: CFString = kSecAttrAccessibleAfterFirstUnlock + ) { + migrationLock.lock() + defer { migrationLock.unlock() } + + let migrationKey = "\(synchronizableMigrationKeyPrefix).\(service)" + guard !UserDefaults.standard.bool(forKey: migrationKey) else { return } + + // Query items with kSecAttrSynchronizable: false (explicit non-synchronizable) + // Using kSecAttrSynchronizableAny would also match already-migrated items. + let loadQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kCFBooleanFalse as Any, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnData as String: true, + kSecReturnAttributes as String: true + ] + + var result: CFTypeRef? + let loadStatus = SecItemCopyMatching(loadQuery as CFDictionary, &result) + + guard loadStatus == errSecSuccess, let items = result as? [[String: Any]] else { + if loadStatus == errSecItemNotFound { + UserDefaults.standard.set(true, forKey: migrationKey) + Log.info("Synchronizable migration: no items to migrate for \(service)") + } else { + Log.error("Synchronizable migration: failed to load items for \(service), status: \(loadStatus)") + } + return + } + + Log.info("Synchronizable migration: migrating \(items.count) item(s) for \(service)") + + var migratedCount = 0 + var failedCount = 0 + + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + let data = item[kSecValueData as String] as? Data else { + Log.warning("Synchronizable migration: skipping item with missing account or data") + failedCount += 1 + continue + } + let genericData = item[kSecAttrGeneric as String] as? Data + + // Add synchronizable copy first, then delete the non-synchronizable original. + // This ensures the data is never left unrecoverable if SecItemAdd fails. + var addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: accessibility, + kSecAttrSynchronizable as String: kCFBooleanTrue as Any, + kSecValueData as String: data + ] + if let genericData { + addQuery[kSecAttrGeneric as String] = genericData + } + + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess || addStatus == errSecDuplicateItem else { + Log.error("Synchronizable migration: failed to add synchronizable item \(account), status: \(addStatus). Original left untouched.") + failedCount += 1 + continue + } + + // Synchronizable copy is now in the keychain, safe to delete the original + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: kCFBooleanFalse as Any + ] + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + Log.error("Synchronizable migration: failed to delete non-synchronizable item \(account) after add, status: \(deleteStatus). Synchronizable copy is in place — will retry next launch.") + failedCount += 1 + continue + } + + migratedCount += 1 + } + + guard failedCount == 0 else { + Log.error("Synchronizable migration: completed with failures, migrated \(migratedCount)/\(items.count) item(s) for \(service). Will retry on next launch") + return + } + + UserDefaults.standard.set(true, forKey: migrationKey) + Log.info("Synchronizable migration: completed, migrated \(migratedCount)/\(items.count) item(s) for \(service)") + } + // MARK: - Public Interface public func generateKeys() throws -> KeychainIdentityKeys { @@ -445,7 +563,8 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol { account: inboxId, service: keychainService, accessGroup: keychainAccessGroup, - accessible: accessibility + accessible: accessibility, + synchronizable: synchronizable ) let data = try loadData(with: query) return try JSONDecoder().decode(KeychainIdentity.self, from: data) @@ -458,7 +577,7 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol { throw KeychainIdentityStoreError.identityNotFound("Invalid clientId encoding: \(clientId)") } - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccessGroup as String: keychainAccessGroup, @@ -466,6 +585,9 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol { kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitAll ] + if synchronizable { + query[kSecAttrSynchronizable as String] = kCFBooleanTrue + } var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -495,13 +617,16 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol { } public func loadAll() throws -> [KeychainIdentity] { - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccessGroup as String: keychainAccessGroup, kSecMatchLimit as String: kSecMatchLimitAll, kSecReturnData as String: true ] + if synchronizable { + query[kSecAttrSynchronizable as String] = kCFBooleanTrue + } var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -531,7 +656,8 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol { account: inboxId, service: keychainService, accessGroup: keychainAccessGroup, - accessible: accessibility + accessible: accessibility, + synchronizable: synchronizable ) try deleteData(with: query) @@ -544,11 +670,14 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol { } public func deleteAll() throws { - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccessGroup as String: keychainAccessGroup ] + if synchronizable { + query[kSecAttrSynchronizable as String] = kCFBooleanTrue + } let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { @@ -579,6 +708,7 @@ public final actor KeychainIdentityStore: KeychainIdentityStoreProtocol { service: keychainService, accessGroup: keychainAccessGroup, accessible: accessibility, + synchronizable: synchronizable, clientId: identity.clientId ) diff --git a/ConvosCore/Sources/ConvosCore/Auth/Keychain/VaultKeyStore.swift b/ConvosCore/Sources/ConvosCore/Auth/Keychain/VaultKeyStore.swift index e294520d9..e13992066 100644 --- a/ConvosCore/Sources/ConvosCore/Auth/Keychain/VaultKeyStore.swift +++ b/ConvosCore/Sources/ConvosCore/Auth/Keychain/VaultKeyStore.swift @@ -28,6 +28,10 @@ public actor VaultKeyStore { return first } + public func loadAll() async throws -> [KeychainIdentity] { + try await store.loadAll() + } + public func exists() async -> Bool { guard let identities = try? await store.loadAll() else { return false } return !identities.isEmpty @@ -37,6 +41,23 @@ public actor VaultKeyStore { try await store.delete(inboxId: inboxId) } + /// Deletes only the local copy of the vault key, preserving the iCloud copy. + /// Use this during "delete all data" flows so the iCloud key remains available + /// for backup decryption on restore. + /// + /// Only effective when the underlying store is an `ICloudIdentityStore` (the + /// production configuration). For non-iCloud stores (mocks/tests with a single + /// keychain), this is a no-op — falling through to a full `delete` would + /// contradict the documented intent of preserving the iCloud copy. Tests that + /// need to verify deletion should call `delete(inboxId:)` directly. + public func deleteLocal(inboxId: String) async throws { + guard let dualStore = store as? ICloudIdentityStore else { + Log.warning("VaultKeyStore.deleteLocal called on non-iCloud store; no-op to preserve documented contract") + return + } + try await dualStore.deleteLocalOnly(inboxId: inboxId) + } + public func deleteAll() async throws { try await store.deleteAll() } diff --git a/ConvosCore/Sources/ConvosCore/Backup/BackupBundle.swift b/ConvosCore/Sources/ConvosCore/Backup/BackupBundle.swift new file mode 100644 index 000000000..9f2494b12 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Backup/BackupBundle.swift @@ -0,0 +1,187 @@ +import Foundation + +public enum BackupBundle { + enum BundleError: Error, LocalizedError { + case directoryCreationFailed(String) + case databaseCopyFailed(String) + case packagingFailed(String) + case unpackingFailed(String) + case missingComponent(String) + + var errorDescription: String? { + switch self { + case .directoryCreationFailed(let reason): + return "Failed to create backup directory: \(reason)" + case .databaseCopyFailed(let reason): + return "Failed to copy database: \(reason)" + case .packagingFailed(let reason): + return "Failed to package backup bundle: \(reason)" + case .unpackingFailed(let reason): + return "Failed to unpack backup bundle: \(reason)" + case .missingComponent(let name): + return "Missing backup component: \(name)" + } + } + } + + private enum Constant { + static let vaultArchiveFilename: String = "vault-archive.encrypted" + static let conversationsDirectory: String = "conversations" + static let databaseFilename: String = "database.sqlite" + } + + static func createStagingDirectory() throws -> URL { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("convos-backup-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: tempDir.appendingPathComponent(Constant.conversationsDirectory, isDirectory: true), + withIntermediateDirectories: true + ) + return tempDir + } + + static func vaultArchivePath(in directory: URL) -> URL { + directory.appendingPathComponent(Constant.vaultArchiveFilename) + } + + static func conversationArchivePath(inboxId: String, in directory: URL) -> URL { + directory + .appendingPathComponent(Constant.conversationsDirectory, isDirectory: true) + .appendingPathComponent("\(inboxId).encrypted") + } + + static func databasePath(in directory: URL) -> URL { + directory.appendingPathComponent(Constant.databaseFilename) + } + + static func copyDatabase(from sourcePath: URL, to directory: URL) throws { + let destination = databasePath(in: directory) + do { + try FileManager.default.copyItem(at: sourcePath, to: destination) + } catch { + throw BundleError.databaseCopyFailed(error.localizedDescription) + } + } + + static func pack(directory: URL, encryptionKey: Data) throws -> Data { + let tarData = try tarDirectory(directory) + return try BackupBundleCrypto.encrypt(data: tarData, key: encryptionKey) + } + + static func unpack(data: Data, encryptionKey: Data, to directory: URL) throws { + let tarData = try BackupBundleCrypto.decrypt(data: data, key: encryptionKey) + try untarData(tarData, to: directory) + } + + static func cleanup(directory: URL) { + try? FileManager.default.removeItem(at: directory) + } + + // MARK: - Archive format: [4-byte path length][path UTF8][8-byte file length][file data]... + + private static func resolvedPath(_ url: URL) -> String { + let path = url.standardizedFileURL.resolvingSymlinksInPath().path + return path.hasSuffix("/") ? String(path.dropLast()) : path + } + + static func tarDirectory(_ directory: URL) throws -> Data { + var archive = Data() + let fileManager = FileManager.default + let resolvedDirPath = resolvedPath(directory) + let resolvedDirURL = URL(fileURLWithPath: resolvedDirPath, isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: resolvedDirURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + throw BundleError.packagingFailed("failed to enumerate directory") + } + + for case let fileURL as URL in enumerator { + let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) + guard resourceValues.isRegularFile == true else { continue } + + let resolvedFilePath = resolvedPath(fileURL) + guard resolvedFilePath.hasPrefix(resolvedDirPath + "/") else { + throw BundleError.packagingFailed("file outside backup directory: \(fileURL.path)") + } + let relativePath = String(resolvedFilePath.dropFirst(resolvedDirPath.count + 1)) + let pathData = Data(relativePath.utf8) + let fileData = try Data(contentsOf: fileURL) + + var pathLength = UInt32(pathData.count).bigEndian + var fileLength = UInt64(fileData.count).bigEndian + archive.append(Data(bytes: &pathLength, count: 4)) + archive.append(pathData) + archive.append(Data(bytes: &fileLength, count: 8)) + archive.append(fileData) + } + + return archive + } + + static func untarData(_ data: Data, to directory: URL) throws { + let fileManager = FileManager.default + let resolvedDirPath = resolvedPath(directory) + var offset = 0 + + while offset < data.count { + guard offset + 4 <= data.count else { + throw BundleError.unpackingFailed("truncated path length") + } + let pathLength = Int(data.subdata(in: offset ..< offset + 4).withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }) + offset += 4 + + guard offset + pathLength <= data.count else { + throw BundleError.unpackingFailed("truncated path data") + } + guard let relativePath = String(data: data.subdata(in: offset ..< offset + pathLength), encoding: .utf8) else { + throw BundleError.unpackingFailed("invalid path encoding") + } + offset += pathLength + + guard offset + 8 <= data.count else { + throw BundleError.unpackingFailed("truncated file length") + } + let fileLengthU64 = data.subdata(in: offset ..< offset + 8) + .withUnsafeBytes { $0.load(as: UInt64.self).bigEndian } + guard fileLengthU64 <= UInt64(Int.max) else { + throw BundleError.unpackingFailed("file length exceeds maximum: \(fileLengthU64)") + } + let fileLength = Int(fileLengthU64) + offset += 8 + + guard offset + fileLength <= data.count else { + throw BundleError.unpackingFailed("truncated file data") + } + let fileData = data.subdata(in: offset ..< offset + fileLength) + offset += fileLength + + let resolvedFileURL = URL(fileURLWithPath: resolvedDirPath) + .appendingPathComponent(relativePath) + + // First-pass containment check on the standardized path. + let standardizedPath = resolvedFileURL.standardizedFileURL.path + guard standardizedPath.hasPrefix(resolvedDirPath + "/") else { + throw BundleError.unpackingFailed("path traversal attempt: \(relativePath)") + } + + // Create the parent directory, then re-validate using the symlink-resolved + // path of the parent. fileData.write(to:) follows symlinks when writing, + // so a pre-existing symlink under the staging dir could escape the + // first-pass check. Re-resolving the parent and re-checking after creation + // catches that case. + let parentDir = resolvedFileURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true) + + let resolvedParentPath = parentDir.standardizedFileURL.resolvingSymlinksInPath().path + guard resolvedParentPath == resolvedDirPath + || resolvedParentPath.hasPrefix(resolvedDirPath + "/") else { + throw BundleError.unpackingFailed("path traversal via symlink: \(relativePath)") + } + + try fileData.write(to: resolvedFileURL) + } + } +} diff --git a/ConvosCore/Sources/ConvosCore/Backup/BackupBundleCrypto.swift b/ConvosCore/Sources/ConvosCore/Backup/BackupBundleCrypto.swift new file mode 100644 index 000000000..213a6a513 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Backup/BackupBundleCrypto.swift @@ -0,0 +1,51 @@ +import CryptoKit +import Foundation + +package enum BackupBundleCrypto { + enum CryptoError: Error, LocalizedError { + case encryptionFailed(String) + case decryptionFailed(String) + case invalidKeyLength + + var errorDescription: String? { + switch self { + case .encryptionFailed(let reason): + return "Backup encryption failed: \(reason)" + case .decryptionFailed(let reason): + return "Backup decryption failed: \(reason)" + case .invalidKeyLength: + return "Encryption key must be 32 bytes" + } + } + } + + static func encrypt(data: Data, key: Data) throws -> Data { + guard key.count == 32 else { + throw CryptoError.invalidKeyLength + } + let symmetricKey = SymmetricKey(data: key) + let sealedBox: AES.GCM.SealedBox + do { + sealedBox = try AES.GCM.seal(data, using: symmetricKey) + } catch { + throw CryptoError.encryptionFailed("\(error)") + } + guard let combined = sealedBox.combined else { + throw CryptoError.encryptionFailed("failed to produce combined representation") + } + return combined + } + + static func decrypt(data: Data, key: Data) throws -> Data { + guard key.count == 32 else { + throw CryptoError.invalidKeyLength + } + let symmetricKey = SymmetricKey(data: key) + do { + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: symmetricKey) + } catch { + throw CryptoError.decryptionFailed("\(error)") + } + } +} diff --git a/ConvosCore/Sources/ConvosCore/Backup/BackupBundleMetadata.swift b/ConvosCore/Sources/ConvosCore/Backup/BackupBundleMetadata.swift new file mode 100644 index 000000000..5ded3fde8 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Backup/BackupBundleMetadata.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct BackupBundleMetadata: Codable, Sendable, Equatable { + public let version: Int + public let createdAt: Date + public let deviceId: String + public let deviceName: String + public let osString: String + public let inboxCount: Int + + public init( + version: Int = 1, + createdAt: Date = Date(), + deviceId: String, + deviceName: String, + osString: String, + inboxCount: Int + ) { + self.version = version + self.createdAt = createdAt + self.deviceId = deviceId + self.deviceName = deviceName + self.osString = osString + self.inboxCount = inboxCount + } + + private enum Constant { + static let metadataFilename: String = "metadata.json" + } + + static func write(_ metadata: BackupBundleMetadata, to directory: URL) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(metadata) + try data.write(to: directory.appendingPathComponent(Constant.metadataFilename)) + } + + public static func read(from directory: URL) throws -> BackupBundleMetadata { + let data = try Data(contentsOf: directory.appendingPathComponent(Constant.metadataFilename)) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(BackupBundleMetadata.self, from: data) + } + + public static func exists(in directory: URL) -> Bool { + FileManager.default.fileExists(atPath: directory.appendingPathComponent(Constant.metadataFilename).path) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Backup/BackupManager.swift b/ConvosCore/Sources/ConvosCore/Backup/BackupManager.swift new file mode 100644 index 000000000..dc45eded0 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Backup/BackupManager.swift @@ -0,0 +1,204 @@ +import Foundation +import GRDB + +public enum BackupError: LocalizedError { + case broadcastKeysFailed(any Error) + + public var errorDescription: String? { + switch self { + case .broadcastKeysFailed(let error): + return "Failed to broadcast conversation keys to vault: \(error.localizedDescription)" + } + } +} + +public struct ConversationArchiveResult: Sendable { + public let inboxId: String + public let success: Bool + public let error: (any Error)? +} + +public protocol BackupArchiveProvider: Sendable { + func broadcastKeysToVault() async throws + func createVaultArchive(at path: URL, encryptionKey: Data) async throws + func createConversationArchive(inboxId: String, at path: String, encryptionKey: Data) async throws +} + +public actor BackupManager { + private let vaultKeyStore: VaultKeyStore + private let archiveProvider: any BackupArchiveProvider + private let identityStore: any KeychainIdentityStoreProtocol + private let databaseReader: any DatabaseReader + private let environment: AppEnvironment + + public init( + vaultKeyStore: VaultKeyStore, + archiveProvider: any BackupArchiveProvider, + identityStore: any KeychainIdentityStoreProtocol, + databaseReader: any DatabaseReader, + environment: AppEnvironment + ) { + self.vaultKeyStore = vaultKeyStore + self.archiveProvider = archiveProvider + self.identityStore = identityStore + self.databaseReader = databaseReader + self.environment = environment + } + + public func createBackup() async throws -> URL { + let vaultIdentity = try await vaultKeyStore.loadAny() + let encryptionKey = vaultIdentity.keys.databaseKey + + let stagingDir = try BackupBundle.createStagingDirectory() + + do { + let (bundleData, metadata) = try await createBundleData( + encryptionKey: encryptionKey, + stagingDir: stagingDir + ) + + let outputURL = try writeToICloudOrLocal(bundleData: bundleData, metadata: metadata) + Log.info("[Backup] saved to \(outputURL.path)") + BackupBundle.cleanup(directory: stagingDir) + return outputURL + } catch { + BackupBundle.cleanup(directory: stagingDir) + throw error + } + } + + private func createBundleData( + encryptionKey: Data, + stagingDir: URL + ) async throws -> (Data, BackupBundleMetadata) { + Log.info("[Backup] broadcasting all conversation keys to vault") + do { + try await archiveProvider.broadcastKeysToVault() + Log.info("[Backup] keys broadcast to vault") + } catch { + // Fail loud: if we can't broadcast keys to the vault, the archive may not + // contain every inbox's key. That would produce a backup that silently + // cannot be fully restored. Better to surface the failure now than create + // an incomplete bundle the user trusts. + Log.error("[Backup] failed to broadcast keys to vault: \(error)") + throw BackupError.broadcastKeysFailed(error) + } + Log.info("[Backup] creating vault archive") + try await createVaultArchive(encryptionKey: encryptionKey, in: stagingDir) + Log.info("[Backup] vault archive created") + + Log.info("[Backup] creating conversation archives") + let conversationResults = await createConversationArchives(in: stagingDir) + let successCount = conversationResults.filter(\.success).count + let failedResults = conversationResults.filter { !$0.success } + Log.info("[Backup] conversation archives: \(successCount)/\(conversationResults.count) succeeded") + if !failedResults.isEmpty { + for result in failedResults { + Log.warning("[Backup] conversation archive failed for \(result.inboxId): \(result.error?.localizedDescription ?? "unknown error")") + } + } + + Log.info("[Backup] copying database snapshot") + try copyDatabase(to: stagingDir) + Log.info("[Backup] database snapshot copied") + + let metadata = BackupBundleMetadata( + deviceId: DeviceInfo.deviceIdentifier, + deviceName: DeviceInfo.deviceName, + osString: DeviceInfo.osString, + inboxCount: successCount + ) + try BackupBundleMetadata.write(metadata, to: stagingDir) + + let bundleData = try BackupBundle.pack(directory: stagingDir, encryptionKey: encryptionKey) + let bundleSizeKB = bundleData.count / 1024 + Log.info("[Backup] bundle packed: \(bundleSizeKB)KB, \(successCount) conversation(s), vault=true, db=true") + return (bundleData, metadata) + } + + private func createVaultArchive(encryptionKey: Data, in directory: URL) async throws { + let archivePath = BackupBundle.vaultArchivePath(in: directory) + try await archiveProvider.createVaultArchive(at: archivePath, encryptionKey: encryptionKey) + } + + private func createConversationArchives(in directory: URL) async -> [ConversationArchiveResult] { + let inboxes: [Inbox] + do { + let repo = InboxesRepository(databaseReader: databaseReader) + inboxes = try repo.nonVaultUsedInboxes() + } catch { + Log.warning("[Backup] failed to load inboxes: \(error)") + return [] + } + + var results: [ConversationArchiveResult] = [] + for inbox in inboxes { + let identity: KeychainIdentity + do { + identity = try await identityStore.identity(for: inbox.inboxId) + } catch { + Log.warning("[Backup] no identity for inbox \(inbox.inboxId), skipping archive") + results.append(.init(inboxId: inbox.inboxId, success: false, error: error)) + continue + } + + let archivePath = BackupBundle.conversationArchivePath(inboxId: inbox.inboxId, in: directory) + do { + try await archiveProvider.createConversationArchive( + inboxId: inbox.inboxId, + at: archivePath.path, + encryptionKey: identity.keys.databaseKey + ) + results.append(.init(inboxId: inbox.inboxId, success: true, error: nil)) + } catch { + Log.warning("[Backup] failed to archive conversation \(inbox.inboxId): \(error)") + results.append(.init(inboxId: inbox.inboxId, success: false, error: error)) + } + } + return results + } + + private func copyDatabase(to directory: URL) throws { + let destinationPath = BackupBundle.databasePath(in: directory) + let destinationQueue = try DatabaseQueue(path: destinationPath.path) + try databaseReader.backup(to: destinationQueue) + } + + private func writeToICloudOrLocal(bundleData: Data, metadata: BackupBundleMetadata) throws -> URL { + let backupDir = try resolveBackupDirectory() + let fileManager = FileManager.default + let bundlePath = backupDir.appendingPathComponent("backup-latest.encrypted") + let tempBundlePath = backupDir.appendingPathComponent("backup-latest.encrypted.tmp") + + try bundleData.write(to: tempBundlePath) + + if fileManager.fileExists(atPath: bundlePath.path) { + _ = try fileManager.replaceItemAt(bundlePath, withItemAt: tempBundlePath) + } else { + try fileManager.moveItem(at: tempBundlePath, to: bundlePath) + } + + try BackupBundleMetadata.write(metadata, to: backupDir) + return bundlePath + } + + private func resolveBackupDirectory() throws -> URL { + let deviceId = DeviceInfo.deviceIdentifier + + if let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: environment.iCloudContainerIdentifier) { + let backupDir = containerURL + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent("backups", isDirectory: true) + .appendingPathComponent(deviceId, isDirectory: true) + try FileManager.default.createDirectory(at: backupDir, withIntermediateDirectories: true) + return backupDir + } + + let localDir = environment.defaultDatabasesDirectoryURL + .appendingPathComponent("backups", isDirectory: true) + .appendingPathComponent(deviceId, isDirectory: true) + try FileManager.default.createDirectory(at: localDir, withIntermediateDirectories: true) + Log.warning("[Backup] iCloud container unavailable, saved locally") + return localDir + } +} diff --git a/ConvosCore/Sources/ConvosCore/Backup/ConvosBackupArchiveProvider.swift b/ConvosCore/Sources/ConvosCore/Backup/ConvosBackupArchiveProvider.swift new file mode 100644 index 000000000..3330c3be1 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Backup/ConvosBackupArchiveProvider.swift @@ -0,0 +1,48 @@ +import Foundation +@preconcurrency import XMTPiOS + +public struct ConvosBackupArchiveProvider: BackupArchiveProvider { + private let vaultService: any VaultServiceProtocol + private let identityStore: any KeychainIdentityStoreProtocol + private let environment: AppEnvironment + + public init( + vaultService: any VaultServiceProtocol, + identityStore: any KeychainIdentityStoreProtocol, + environment: AppEnvironment + ) { + self.vaultService = vaultService + self.identityStore = identityStore + self.environment = environment + } + + public func broadcastKeysToVault() async throws { + guard let vaultManager = vaultService as? VaultManager else { return } + try await vaultManager.shareAllKeys() + } + + public func createVaultArchive(at path: URL, encryptionKey: Data) async throws { + try await vaultService.createArchive(at: path, encryptionKey: encryptionKey) + } + + public func createConversationArchive(inboxId: String, at path: String, encryptionKey: Data) async throws { + let identity = try await identityStore.identity(for: inboxId) + let client = try await buildClient(identity: identity, inboxId: inboxId) + defer { try? client.dropLocalDatabaseConnection() } + try await client.createArchive(path: path, encryptionKey: encryptionKey) + } + + private func buildClient(identity: KeychainIdentity, inboxId: String) async throws -> Client { + let api = XMTPAPIOptionsBuilder.build(environment: environment) + let options = ClientOptions( + api: api, + dbEncryptionKey: identity.keys.databaseKey, + dbDirectory: environment.defaultDatabasesDirectory + ) + return try await Client.build( + publicIdentity: identity.keys.signingKey.identity, + options: options, + inboxId: inboxId + ) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Backup/ConvosRestoreArchiveImporter.swift b/ConvosCore/Sources/ConvosCore/Backup/ConvosRestoreArchiveImporter.swift new file mode 100644 index 000000000..0aa69e1fa --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Backup/ConvosRestoreArchiveImporter.swift @@ -0,0 +1,55 @@ +import Foundation +@preconcurrency import XMTPiOS + +public struct ConvosRestoreArchiveImporter: RestoreArchiveImporter { + private let identityStore: any KeychainIdentityStoreProtocol + private let environment: AppEnvironment + + public init( + identityStore: any KeychainIdentityStoreProtocol, + environment: AppEnvironment + ) { + self.identityStore = identityStore + self.environment = environment + } + + public func importConversationArchive(inboxId: String, path: String, encryptionKey: Data) async throws -> String { + // RestoreManager has already staged/wiped the local XMTP DBs for conversation + // inboxes before calling us, so no existing client can be reused here. Create + // a single fresh client and import the archive into it — any prior `Client.build` + // probe would register an extra installation on the network as a side effect. + let identity = try await identityStore.identity(for: inboxId) + let api = XMTPAPIOptionsBuilder.build(environment: environment) + let options = ClientOptions( + api: api, + dbEncryptionKey: identity.keys.databaseKey, + dbDirectory: environment.defaultDatabasesDirectory, + deviceSyncEnabled: false + ) + + let client = try await Client.create( + account: identity.keys.signingKey, + options: options + ) + defer { try? client.dropLocalDatabaseConnection() } + + try await client.importArchive(path: path, encryptionKey: encryptionKey) + + // After archive import, the XMTP SDK's consent state for restored + // groups may be 'unknown'. shouldProcessConversation in StreamProcessor + // drops messages from groups that aren't 'allowed', so the first + // incoming message after restore would be silently lost. Set consent + // to 'allowed' for all groups in this inbox's archive. + let groups = try client.conversations.listGroups() + for group in groups { + try await group.updateConsentState(state: .allowed) + } + if !groups.isEmpty { + Log.info("[Restore] set consent=allowed for \(groups.count) group(s) in \(inboxId)") + } + + let newInstallationId = client.installationID + Log.info("[Restore] conversation archive imported for \(inboxId) (new installationId=\(newInstallationId))") + return newInstallationId + } +} diff --git a/ConvosCore/Sources/ConvosCore/Backup/ConvosVaultArchiveImporter.swift b/ConvosCore/Sources/ConvosCore/Backup/ConvosVaultArchiveImporter.swift new file mode 100644 index 000000000..345b888b9 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Backup/ConvosVaultArchiveImporter.swift @@ -0,0 +1,92 @@ +import ConvosInvites +import Foundation +@preconcurrency import XMTPiOS + +public struct ConvosVaultArchiveImporter: VaultArchiveImporter { + private let environment: AppEnvironment + + public init(environment: AppEnvironment) { + self.environment = environment + } + + public func importVaultArchive( + from path: URL, + encryptionKey: Data, + vaultIdentity: KeychainIdentity + ) async throws -> [VaultKeyEntry] { + let api = XMTPAPIOptionsBuilder.build(environment: environment) + + let codecs: [any ContentCodec] = [ + ConversationDeletedCodec(), + DeviceKeyBundleCodec(), + DeviceKeyShareCodec(), + DeviceRemovedCodec(), + JoinRequestCodec(), + PairingMessageCodec(), + TextCodec(), + ] + + // Always import the archive from the backup into an isolated temp + // directory. Reusing an existing vault XMTP DB on disk is wrong + // when the keychain holds multiple vault identities (e.g. after + // iCloud Keychain sync) — loadAny() might return the restoring + // device's vault instead of the backup device's, and the existing + // DB would contain a different set of key messages. + let importDir = FileManager.default.temporaryDirectory + .appendingPathComponent("xmtp-vault-import-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: importDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: importDir) + } + Log.info("[Restore] importing vault archive into isolated directory (inboxId=\(vaultIdentity.inboxId))") + + let importOptions = ClientOptions( + api: api, + codecs: codecs, + dbEncryptionKey: vaultIdentity.keys.databaseKey, + dbDirectory: importDir.path, + deviceSyncEnabled: false + ) + + let client = try await Client.create( + account: vaultIdentity.keys.signingKey, + options: importOptions + ) + defer { try? client.dropLocalDatabaseConnection() } + + Log.info("[Restore] importing vault archive (client inboxId=\(client.inboxID))") + try await client.importArchive(path: path.path, encryptionKey: encryptionKey) + Log.info("[Restore] vault archive import succeeded") + + return try await extractKeys(from: client) + } + + private func extractKeys(from client: Client) async throws -> [VaultKeyEntry] { + // Read groups and messages from the local DB only — no network sync. + // The archive import already populated the local DB with everything + // we need, and syncing would fail if the vault group was deactivated + // on the network by a previous restore's revocation step. + let groups = try client.conversations.listGroups() + + Log.info("[Restore] reading messages from \(groups.count) vault group(s)") + var allMessages: [DecodedMessage] = [] + for group in groups { + let messages = try await group.messages() + allMessages.append(contentsOf: messages) + } + + var bundles: [DeviceKeyBundleContent] = [] + var shares: [DeviceKeyShareContent] = [] + for message in allMessages { + if let bundle: DeviceKeyBundleContent = try? message.content() { + bundles.append(bundle) + } else if let share: DeviceKeyShareContent = try? message.content() { + shares.append(share) + } + } + + let entries = VaultManager.extractKeyEntries(bundles: bundles, shares: shares) + Log.info("[Restore] extracted \(entries.count) key entries from \(bundles.count) bundle(s) and \(shares.count) share(s)") + return entries + } +} diff --git a/ConvosCore/Sources/ConvosCore/Backup/RestoreManager.swift b/ConvosCore/Sources/ConvosCore/Backup/RestoreManager.swift new file mode 100644 index 000000000..6498b8f0b --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Backup/RestoreManager.swift @@ -0,0 +1,597 @@ +import Foundation +import GRDB +@preconcurrency import XMTPiOS + +public enum RestoreState: Sendable, Equatable { + case idle + case decrypting + case importingVault + case savingKeys(completed: Int, total: Int) + case replacingDatabase + case importingConversations(completed: Int, total: Int) + case completed(inboxCount: Int, failedKeyCount: Int) + case failed(String) +} + +public protocol RestoreArchiveImporter: Sendable { + /// Import a conversation archive into a fresh XMTP client and return the + /// installation id that was registered for this inbox on the network. The + /// caller uses this id as the "keeper" when revoking older installations. + func importConversationArchive(inboxId: String, path: String, encryptionKey: Data) async throws -> String +} + +public protocol VaultArchiveImporter: Sendable { + func importVaultArchive(from path: URL, encryptionKey: Data, vaultIdentity: KeychainIdentity) async throws -> [VaultKeyEntry] +} + +public protocol RestoreLifecycleControlling: Sendable { + func prepareForRestore() async + func finishRestore() async +} + +/// Closure that revokes every installation for `inboxId` except `keepInstallationId`. +/// Return value is the number of installations revoked. Default production +/// implementation wraps `XMTPInstallationRevoker`; tests pass `nil` to skip. +public typealias RestoreInstallationRevoker = @Sendable ( + _ inboxId: String, + _ signingKey: any SigningKey, + _ keepInstallationId: String? +) async throws -> Int + +public actor RestoreManager { + private let vaultKeyStore: VaultKeyStore + private let vaultArchiveImporter: any VaultArchiveImporter + private let identityStore: any KeychainIdentityStoreProtocol + private let databaseManager: any DatabaseManagerProtocol + private let archiveImporter: any RestoreArchiveImporter + private let restoreLifecycleController: (any RestoreLifecycleControlling)? + private let vaultManager: VaultManager? + private let environment: AppEnvironment + private let installationRevoker: RestoreInstallationRevoker? + + public private(set) var state: RestoreState = .idle + + public init( + vaultKeyStore: VaultKeyStore, + vaultArchiveImporter: (any VaultArchiveImporter)? = nil, + identityStore: any KeychainIdentityStoreProtocol, + databaseManager: any DatabaseManagerProtocol, + archiveImporter: any RestoreArchiveImporter, + restoreLifecycleController: (any RestoreLifecycleControlling)? = nil, + vaultManager: VaultManager? = nil, + installationRevoker: RestoreInstallationRevoker? = nil, + environment: AppEnvironment + ) { + self.vaultKeyStore = vaultKeyStore + self.vaultArchiveImporter = vaultArchiveImporter ?? ConvosVaultArchiveImporter( + environment: environment + ) + self.identityStore = identityStore + self.databaseManager = databaseManager + self.archiveImporter = archiveImporter + self.restoreLifecycleController = restoreLifecycleController + self.vaultManager = vaultManager + self.installationRevoker = installationRevoker + self.environment = environment + } + + public func restoreFromBackup(bundleURL: URL) async throws { + state = .decrypting + let stagingDir = try BackupBundle.createStagingDirectory() + var preparedForRestore = false + + // Rollback state: populated once destructive operations begin, cleared once + // the restore is committed (DB replaced + keys saved). If an error is thrown + // before commit, we use these to restore the pre-restore state of the device. + var xmtpStashDir: URL? + var preRestoreIdentities: [KeychainIdentity] = [] + var destructiveOpsStarted = false + var committed = false + + do { + Log.info("[Restore] reading bundle (\(bundleURL.lastPathComponent))") + let bundleData = try Data(contentsOf: bundleURL) + + let (encryptionKey, vaultIdentity) = try await decryptBundle( + bundleData: bundleData, + to: stagingDir + ) + Log.info("[Restore] decrypted with vault identity inboxId=\(vaultIdentity.inboxId)") + + let metadata = try BackupBundleMetadata.read(from: stagingDir) + Log.info("[Restore] backup v\(metadata.version) from \(metadata.deviceName) (\(metadata.createdAt))") + + if let restoreLifecycleController { + Log.info("[Restore] stopping sessions") + await restoreLifecycleController.prepareForRestore() + preparedForRestore = true + Log.info("[Restore] sessions stopped") + } + + Log.info("[Restore] importing vault archive and extracting keys") + let keyEntries = try await importVaultArchive( + encryptionKey: encryptionKey, + vaultIdentity: vaultIdentity, + in: stagingDir + ) + Log.info("[Restore] extracted \(keyEntries.count) key(s) from vault archive") + + if keyEntries.isEmpty, metadata.inboxCount > 0 { + Log.error("[Restore] backup contains \(metadata.inboxCount) conversation(s) but vault yielded 0 keys — aborting before destructive operations") + throw RestoreError.incompleteBackup(inboxCount: metadata.inboxCount) + } + + Log.info("[Restore] snapshotting existing keychain identities for rollback") + preRestoreIdentities = (try? await identityStore.loadAll()) ?? [] + Log.info("[Restore] snapshotted \(preRestoreIdentities.count) identity/identities") + + destructiveOpsStarted = true + Log.info("[Restore] staging local XMTP files aside") + xmtpStashDir = try stageXMTPFiles() + Log.info("[Restore] XMTP files staged") + + Log.info("[Restore] clearing keychain identities") + do { + try await identityStore.deleteAll() + } catch { + Log.warning("[Restore] failed to clear conversation keychain identities: \(error)") + } + + Log.info("[Restore] saving keys to keychain") + let failedKeyCount = await saveKeysToKeychain(entries: keyEntries) + Log.info("[Restore] keys saved (\(failedKeyCount) failed)") + if !keyEntries.isEmpty, failedKeyCount == keyEntries.count { + // Every single key failed to save — keychain is empty and DB replace + // would leave the device unable to decrypt any restored conversation. + // Abort before touching the database. + throw RestoreError.keychainRestoreFailed + } + + Log.info("[Restore] replacing database") + try replaceDatabase(from: stagingDir) + Log.info("[Restore] database replaced") + + // Commit point: DB + keychain are consistent with the restored state. + // The staged XMTP files are stale and can be discarded; past this point + // any failures are non-fatal and do not roll back. + committed = true + if let stash = xmtpStashDir { + deleteStagedXMTPFiles(at: stash) + xmtpStashDir = nil + } + + Log.info("[Restore] importing conversation archives") + let importedInboxes = await importConversationArchives(in: stagingDir) + Log.info("[Restore] conversation archives imported (\(importedInboxes.count) inbox(es))") + + await revokeStaleInstallationsForRestoredInboxes(importedInboxes) + + Log.info("[Restore] marking all conversations inactive") + let localStateWriter = ConversationLocalStateWriter(databaseWriter: databaseManager.dbWriter) + do { + try await localStateWriter.markAllConversationsInactive() + Log.info("[Restore] conversations marked inactive") + } catch { + Log.error("[Restore] failed to mark conversations inactive: \(error)") + } + + await reCreateVault() + + if preparedForRestore { + Log.info("[Restore] resuming sessions") + await restoreLifecycleController?.finishRestore() + Log.info("[Restore] sessions resumed") + } + + let restoredCount = try countRestoredInboxes() + state = .completed(inboxCount: restoredCount, failedKeyCount: failedKeyCount) + Log.info("[Restore] completed: \(restoredCount) inbox(es), \(keyEntries.count) key(s), \(failedKeyCount) key failure(s)") + + BackupBundle.cleanup(directory: stagingDir) + } catch { + if !committed, destructiveOpsStarted { + Log.warning("[Restore] rolling back keychain and XMTP state after failure: \(error)") + await rollbackKeychain(to: preRestoreIdentities) + if let stash = xmtpStashDir { + restoreStagedXMTPFiles(from: stash) + } + } + if preparedForRestore { + await restoreLifecycleController?.finishRestore() + } + state = .failed(error.localizedDescription) + BackupBundle.cleanup(directory: stagingDir) + throw error + } + } + + // MARK: - Staging / rollback + + private func stageXMTPFiles() throws -> URL { + let fileManager = FileManager.default + let stashDir = fileManager.temporaryDirectory + .appendingPathComponent("xmtp-restore-stash-\(UUID().uuidString)") + try fileManager.createDirectory(at: stashDir, withIntermediateDirectories: true) + + let sourceDir = environment.defaultDatabasesDirectoryURL + guard let files = try? fileManager.contentsOfDirectory( + at: sourceDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return stashDir + } + + var moved = 0 + for file in files where file.lastPathComponent.hasPrefix("xmtp-") && + !file.lastPathComponent.hasPrefix("xmtp-restore-stash-") { + let destination = stashDir.appendingPathComponent(file.lastPathComponent) + do { + try fileManager.moveItem(at: file, to: destination) + moved += 1 + } catch { + Log.warning("[Restore] failed to stage XMTP file \(file.lastPathComponent): \(error)") + } + } + Log.info("[Restore] staged \(moved) XMTP file(s) to \(stashDir.lastPathComponent)") + return stashDir + } + + private func restoreStagedXMTPFiles(from stashDir: URL) { + let fileManager = FileManager.default + let destinationDir = environment.defaultDatabasesDirectoryURL + + guard let files = try? fileManager.contentsOfDirectory( + at: stashDir, + includingPropertiesForKeys: nil + ) else { + try? fileManager.removeItem(at: stashDir) + return + } + + for file in files { + let destination = destinationDir.appendingPathComponent(file.lastPathComponent) + try? fileManager.removeItem(at: destination) + do { + try fileManager.moveItem(at: file, to: destination) + } catch { + Log.warning("[Restore] failed to restore staged XMTP file \(file.lastPathComponent): \(error)") + } + } + try? fileManager.removeItem(at: stashDir) + Log.info("[Restore] restored staged XMTP files") + } + + private func deleteStagedXMTPFiles(at stashDir: URL) { + try? FileManager.default.removeItem(at: stashDir) + } + + private func rollbackKeychain(to snapshot: [KeychainIdentity]) async { + do { + try await identityStore.deleteAll() + } catch { + Log.warning("[Restore] rollback: failed to clear keychain before restoring snapshot: \(error)") + } + for identity in snapshot { + do { + _ = try await identityStore.save( + inboxId: identity.inboxId, + clientId: identity.clientId, + keys: identity.keys + ) + } catch { + Log.warning("[Restore] rollback: failed to restore identity \(identity.inboxId): \(error)") + } + } + } + + // MARK: - Vault re-creation + + private func reCreateVault() async { + Log.info("[Restore.reCreateVault] === START ===") + + guard let vaultManager else { + Log.warning("[Restore.reCreateVault] no VaultManager provided, skipping vault re-creation") + return + } + + let vaultInboxBefore = await vaultManager.vaultInboxId ?? "nil" + Log.info("[Restore.reCreateVault] vault inboxId before re-create: \(vaultInboxBefore)") + + Log.info("[Restore.reCreateVault] calling VaultManager.reCreate") + do { + try await vaultManager.reCreate( + databaseWriter: databaseManager.dbWriter, + environment: environment + ) + let vaultInboxAfter = await vaultManager.vaultInboxId ?? "nil" + Log.info("[Restore.reCreateVault] vault re-created successfully, new inboxId=\(vaultInboxAfter)") + + let keyCount = (try? await databaseManager.dbReader.read { db in + try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM inbox WHERE isVault = 0 + """) ?? 0 + }) ?? 0 + Log.info("[Restore.reCreateVault] broadcasting restored conversation keys to new vault (\(keyCount) conversation inbox(es))") + + do { + try await vaultManager.shareAllKeys() + Log.info("[Restore.reCreateVault] broadcast complete") + } catch { + Log.warning("[Restore.reCreateVault] broadcast failed (non-fatal): \(error)") + } + } catch { + Log.error("[Restore.reCreateVault] vault re-creation failed: \(error)") + } + + Log.info("[Restore.reCreateVault] === DONE ===") + } + + // MARK: - Bundle decryption + + private func decryptBundle( + bundleData: Data, + to stagingDir: URL + ) async throws -> (encryptionKey: Data, identity: KeychainIdentity) { + let identities = try await vaultKeyStore.loadAll() + guard !identities.isEmpty else { + throw RestoreError.noVaultKey + } + + for identity in identities { + let key = identity.keys.databaseKey + do { + Log.info("[Restore] trying vault key (inboxId=\(identity.inboxId))") + try BackupBundle.unpack(data: bundleData, encryptionKey: key, to: stagingDir) + Log.info("[Restore] decryption succeeded with vault key (inboxId=\(identity.inboxId))") + return (key, identity) + } catch { + Log.info("[Restore] vault key (inboxId=\(identity.inboxId)) failed: \(error)") + // Reset staging dir for the next attempt. If reset fails (e.g. disk full), + // log and continue — let the loop try the next key, then surface + // RestoreError.decryptionFailed at the end. + do { + BackupBundle.cleanup(directory: stagingDir) + try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) + } catch { + Log.warning("[Restore] failed to reset staging directory between key attempts: \(error)") + } + } + } + + throw RestoreError.decryptionFailed + } + + // MARK: - Vault archive import + + private func importVaultArchive( + encryptionKey: Data, + vaultIdentity: KeychainIdentity, + in directory: URL + ) async throws -> [VaultKeyEntry] { + state = .importingVault + let vaultArchivePath = BackupBundle.vaultArchivePath(in: directory) + + // Without a vault archive, we have no conversation keys to restore. Continuing + // would wipe local state and replace the database with nothing to decrypt the + // resulting conversations — silent data loss. Bail before any destructive op. + guard FileManager.default.fileExists(atPath: vaultArchivePath.path) else { + Log.error("[Restore] vault archive missing from bundle — aborting before destructive operations") + throw RestoreError.missingVaultArchive + } + + return try await vaultArchiveImporter.importVaultArchive( + from: vaultArchivePath, + encryptionKey: encryptionKey, + vaultIdentity: vaultIdentity + ) + } + + // MARK: - Key restoration + + @discardableResult + private func saveKeysToKeychain(entries: [VaultKeyEntry]) async -> Int { + var failedCount = 0 + for (index, entry) in entries.enumerated() { + state = .savingKeys(completed: index, total: entries.count) + + do { + let keys = try KeychainIdentityKeys( + privateKeyData: entry.privateKeyData, + databaseKey: entry.databaseKey + ) + _ = try await identityStore.save( + inboxId: entry.inboxId, + clientId: entry.clientId, + keys: keys + ) + } catch { + failedCount += 1 + Log.warning("Failed to save key for inbox \(entry.inboxId): \(error)") + } + } + state = .savingKeys(completed: entries.count, total: entries.count) + return failedCount + } + + // MARK: - Database replacement + + private func replaceDatabase(from directory: URL) throws { + state = .replacingDatabase + let backupDbPath = BackupBundle.databasePath(in: directory) + + guard FileManager.default.fileExists(atPath: backupDbPath.path) else { + throw RestoreError.missingDatabase + } + + try databaseManager.replaceDatabase(with: backupDbPath) + } + + // MARK: - Conversation archive import + + /// Returns the set of `(inboxId, newInstallationId)` pairs for every + /// conversation archive that was successfully imported. The installation id + /// is the one registered on the XMTP network during archive import — it is + /// the "keeper" for post-restore revocation of stale installations. + private func importConversationArchives(in directory: URL) async -> [(inboxId: String, newInstallationId: String)] { + let conversationsDir = directory + .appendingPathComponent("conversations", isDirectory: true) + + let fileManager = FileManager.default + guard let contents = try? fileManager.contentsOfDirectory( + at: conversationsDir, + includingPropertiesForKeys: nil + ) else { + Log.info("No conversation archives to import") + return [] + } + + let archiveFiles = contents.filter { $0.pathExtension == "encrypted" } + var completed = 0 + var imported: [(inboxId: String, newInstallationId: String)] = [] + + for archiveFile in archiveFiles { + let inboxId = archiveFile.deletingPathExtension().lastPathComponent + state = .importingConversations(completed: completed, total: archiveFiles.count) + + let identity: KeychainIdentity + do { + identity = try await identityStore.identity(for: inboxId) + } catch { + Log.warning("No identity for conversation archive \(inboxId), skipping") + completed += 1 + continue + } + + do { + let newInstallationId = try await archiveImporter.importConversationArchive( + inboxId: inboxId, + path: archiveFile.path, + encryptionKey: identity.keys.databaseKey + ) + imported.append((inboxId: inboxId, newInstallationId: newInstallationId)) + } catch { + Log.warning("Failed to import conversation archive \(inboxId): \(error)") + } + completed += 1 + } + state = .importingConversations(completed: completed, total: archiveFiles.count) + return imported + } + + /// After a successful archive import on device B, every inbox has a brand + /// new installation on the network (the one we just created) alongside the + /// original installations from device A. Revoke every installation *except* + /// the one we just created so that device A flips to `stale` on its next + /// foreground cycle and stops diverging from the restored state. + private func revokeStaleInstallationsForRestoredInboxes( + _ imported: [(inboxId: String, newInstallationId: String)] + ) async { + guard let installationRevoker else { + Log.info("[Restore] installationRevoker not configured, skipping post-import revocation") + return + } + guard !imported.isEmpty else { return } + + Log.info("[Restore] revoking stale installations for \(imported.count) restored inbox(es)") + for entry in imported { + let identity: KeychainIdentity + do { + identity = try await identityStore.identity(for: entry.inboxId) + } catch { + Log.warning("[Restore] cannot load identity for \(entry.inboxId), skipping revocation: \(error)") + continue + } + do { + let revoked = try await installationRevoker( + entry.inboxId, + identity.keys.signingKey, + entry.newInstallationId + ) + Log.info("[Restore] revoked \(revoked) stale installation(s) for \(entry.inboxId)") + } catch { + Log.warning("[Restore] revocation failed for \(entry.inboxId) (non-fatal): \(error)") + } + } + } + + // MARK: - Helpers + + private func countRestoredInboxes() throws -> Int { + let repo = InboxesRepository(databaseReader: databaseManager.dbReader) + return (try? repo.nonVaultUsedInboxes().count) ?? 0 + } + + // MARK: - Restore detection + + public nonisolated static func findAvailableBackup( + environment: AppEnvironment + ) -> (url: URL, metadata: BackupBundleMetadata)? { + let containerId = environment.iCloudContainerIdentifier + + if let containerURL = FileManager.default.url(forUbiquityContainerIdentifier: containerId) { + let backupsDir = containerURL + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent("backups", isDirectory: true) + if let backup = findNewestBackup(in: backupsDir) { + return backup + } + } + + let localBackupsDir = environment.defaultDatabasesDirectoryURL + .appendingPathComponent("backups", isDirectory: true) + return findNewestBackup(in: localBackupsDir) + } + + nonisolated static func findNewestBackup( + in backupsDir: URL + ) -> (url: URL, metadata: BackupBundleMetadata)? { + let fileManager = FileManager.default + guard let deviceDirs = try? fileManager.contentsOfDirectory( + at: backupsDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return nil + } + + var newest: (url: URL, metadata: BackupBundleMetadata)? + for deviceDir in deviceDirs { + guard BackupBundleMetadata.exists(in: deviceDir) else { continue } + guard let metadata = try? BackupBundleMetadata.read(from: deviceDir) else { continue } + let bundleURL = deviceDir.appendingPathComponent("backup-latest.encrypted") + guard fileManager.fileExists(atPath: bundleURL.path) else { continue } + + if newest == nil || metadata.createdAt > newest?.metadata.createdAt ?? .distantPast { + newest = (url: bundleURL, metadata: metadata) + } + } + return newest + } + + private enum RestoreError: LocalizedError { + case noVaultKey + case decryptionFailed + case missingVaultArchive + case missingDatabase + case keychainRestoreFailed + case incompleteBackup(inboxCount: Int) + + var errorDescription: String? { + switch self { + case .noVaultKey: + return "No vault key found in keychain" + case .decryptionFailed: + return "None of the available vault keys could decrypt this backup" + case .missingVaultArchive: + return "Backup bundle does not contain a vault archive" + case .missingDatabase: + return "Backup bundle does not contain a database" + case .keychainRestoreFailed: + return "Failed to save any restored keys to the keychain" + case .incompleteBackup(let inboxCount): + return "Backup contains \(inboxCount) conversation(s) but the vault archive yielded no decryption keys. The backup may have been created before keys were broadcast to the vault." + } + } + } +} diff --git a/ConvosCore/Sources/ConvosCore/Backup/XMTPInstallationRevoker.swift b/ConvosCore/Sources/ConvosCore/Backup/XMTPInstallationRevoker.swift new file mode 100644 index 000000000..ca66f92e4 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Backup/XMTPInstallationRevoker.swift @@ -0,0 +1,44 @@ +import Foundation +@preconcurrency import XMTPiOS + +public enum XMTPInstallationRevoker { + @discardableResult + public static func revokeOtherInstallations( + inboxId: String, + signingKey: any SigningKey, + keepInstallationId: String?, + environment: AppEnvironment + ) async throws -> Int { + let api = XMTPAPIOptionsBuilder.build(environment: environment) + + Log.info("[Revoke] fetching inbox state for \(inboxId)") + let states = try await Client.inboxStatesForInboxIds( + inboxIds: [inboxId], + api: api + ) + + guard let state = states.first else { + Log.warning("[Revoke] no inbox state found") + return 0 + } + + let allIds = state.installations.map(\.id) + let toRevoke = allIds.filter { $0 != keepInstallationId } + + Log.info("[Revoke] found \(allIds.count) installation(s), revoking \(toRevoke.count) (keeping \(keepInstallationId ?? "none"))") + + guard !toRevoke.isEmpty else { + return 0 + } + + try await Client.revokeInstallations( + api: api, + signingKey: signingKey, + inboxId: inboxId, + installationIds: toRevoke + ) + + Log.info("[Revoke] revoked \(toRevoke.count) installation(s)") + return toRevoke.count + } +} diff --git a/ConvosCore/Sources/ConvosCore/ConvosClient+App.swift b/ConvosCore/Sources/ConvosCore/ConvosClient+App.swift index c995bc727..f568dfdff 100644 --- a/ConvosCore/Sources/ConvosCore/ConvosClient+App.swift +++ b/ConvosCore/Sources/ConvosCore/ConvosClient+App.swift @@ -27,6 +27,11 @@ extension ConvosClient { service: Constant.vaultICloudIdentityService, accessibility: kSecAttrAccessibleAfterFirstUnlock ) + KeychainIdentityStore.migrateToSynchronizableIfNeeded( + accessGroup: keychainAccessGroup, + service: Constant.vaultICloudIdentityService, + accessibility: kSecAttrAccessibleAfterFirstUnlock + ) let identityStore = KeychainIdentityStore(accessGroup: keychainAccessGroup) let localVaultKeychainStore = KeychainIdentityStore( @@ -37,7 +42,8 @@ extension ConvosClient { let iCloudVaultKeychainStore = KeychainIdentityStore( accessGroup: keychainAccessGroup, service: Constant.vaultICloudIdentityService, - accessibility: kSecAttrAccessibleAfterFirstUnlock + accessibility: kSecAttrAccessibleAfterFirstUnlock, + synchronizable: true ) let vaultKeychainStore = ICloudIdentityStore( localStore: localVaultKeychainStore, diff --git a/ConvosCore/Sources/ConvosCore/ConvosClient.swift b/ConvosCore/Sources/ConvosCore/ConvosClient.swift index 13e1b4bf4..224b3d752 100644 --- a/ConvosCore/Sources/ConvosCore/ConvosClient.swift +++ b/ConvosCore/Sources/ConvosCore/ConvosClient.swift @@ -11,7 +11,7 @@ import GRDB /// persistent storage). public final class ConvosClient { private let sessionManager: any SessionManagerProtocol - private let databaseManager: any DatabaseManagerProtocol + public let databaseManager: any DatabaseManagerProtocol private let environment: AppEnvironment public let expiredConversationsWorker: ExpiredConversationsWorkerProtocol? public let scheduledExplosionManager: ScheduledExplosionManagerProtocol? diff --git a/ConvosCore/Sources/ConvosCore/Inboxes/InboxStateMachine.swift b/ConvosCore/Sources/ConvosCore/Inboxes/InboxStateMachine.swift index e6784d125..71ba98c13 100644 --- a/ConvosCore/Sources/ConvosCore/Inboxes/InboxStateMachine.swift +++ b/ConvosCore/Sources/ConvosCore/Inboxes/InboxStateMachine.swift @@ -50,6 +50,10 @@ public struct InboxReadyResult: @unchecked Sendable { typealias AnySyncingManager = (any SyncingManagerProtocol) typealias AnyInviteJoinRequestsManager = (any InviteJoinRequestsManagerProtocol) +typealias RevokeInstallationsHandler = ( + _ client: any XMTPClientProvider, + _ signingKey: any SigningKey +) async throws -> Void // swiftlint:disable type_body_length @@ -107,6 +111,7 @@ public actor InboxStateMachine: InboxStateManagerProtocol { private let apiClient: any ConvosAPIClientProtocol private let networkMonitor: any NetworkMonitorProtocol private let appLifecycle: any AppLifecycleProviding + private let revokeInstallationsHandler: RevokeInstallationsHandler private var requestDeviceSyncAfterAuthorize: Bool = false private var currentTask: Task? @@ -276,7 +281,10 @@ public actor InboxStateMachine: InboxStateManagerProtocol { overrideJWTToken: String? = nil, environment: AppEnvironment, appLifecycle: any AppLifecycleProviding, - apiClient: (any ConvosAPIClientProtocol)? = nil + apiClient: (any ConvosAPIClientProtocol)? = nil, + revokeInstallationsHandler: @escaping RevokeInstallationsHandler = { client, signingKey in + try await client.revokeAllOtherInstallations(signingKey: signingKey) + } ) { let initialState: State = .idle(clientId: clientId) self.initialClientId = clientId @@ -290,6 +298,7 @@ public actor InboxStateMachine: InboxStateManagerProtocol { self.overrideJWTToken = overrideJWTToken ?? environment.defaultOverrideJWTToken self.environment = environment self.appLifecycle = appLifecycle + self.revokeInstallationsHandler = revokeInstallationsHandler // Use provided API client or create a new one if let apiClient { @@ -704,6 +713,68 @@ public actor InboxStateMachine: InboxStateManagerProtocol { } } + let storedInstallationId: String? + do { + storedInstallationId = try await databaseWriter.read { db in + try DBInbox.fetchOne(db, id: result.client.inboxId)?.installationId + } + } catch { + Log.warning("Failed to read stored installationId for inbox \(result.client.inboxId), skipping revocation check (non-fatal): \(error)") + storedInstallationId = nil + } + + Log.info("[Revoke] inbox \(result.client.inboxId): storedInstallationId=\(storedInstallationId ?? "nil") newInstallationId=\(result.client.installationId)") + + if let storedInstallationId { + if storedInstallationId != result.client.installationId { + Log.info("[Revoke] inbox \(result.client.inboxId): installationId changed, revoking all other installations") + do { + let identity = try await identityStore.identity(for: result.client.inboxId) + try await revokeInstallationsHandler(result.client, identity.keys.signingKey) + Log.info("[Revoke] inbox \(result.client.inboxId): revoked all other installations ✓") + } catch { + Log.warning("[Revoke] inbox \(result.client.inboxId): revocation failed (non-fatal): \(error)") + } + } else { + Log.info("[Revoke] inbox \(result.client.inboxId): installationId unchanged, skipping") + } + } else { + Log.info("[Revoke] inbox \(result.client.inboxId): no stored installationId, skipping (first-time auth)") + } + + do { + let inboxWriter = InboxWriter(dbWriter: databaseWriter) + _ = try await inboxWriter.save( + inboxId: result.client.inboxId, + clientId: clientId, + installationId: result.client.installationId + ) + } catch { + Log.error("Failed to persist installationId for inbox \(result.client.inboxId) (non-fatal): \(error)") + } + + // Check whether this installation is still active (detects revocation by another device) + let staleInboxId = result.client.inboxId + let staleIsActive: Bool? + do { + staleIsActive = try await result.client.isInstallationActive() + } catch { + Log.warning("[Stale] inbox \(staleInboxId): check failed (non-fatal): \(error)") + staleIsActive = nil + } + if let staleIsActive { + if staleIsActive { + Log.info("[Stale] inbox \(staleInboxId): installation is active ✓") + let writer = InboxWriter(dbWriter: databaseWriter) + try? await writer.markStale(inboxId: staleInboxId, false) + } else { + Log.warning("[Stale] inbox \(staleInboxId): installation NOT in active list — marking stale") + let writer = InboxWriter(dbWriter: databaseWriter) + try? await writer.markStale(inboxId: staleInboxId, true) + QAEvent.emit(.inbox, "stale_detected", ["inboxId": staleInboxId]) + } + } + await syncingManager?.start(with: result.client, apiClient: result.apiClient) foregroundRetryCount = 0 @@ -896,6 +967,27 @@ public actor InboxStateMachine: InboxStateManagerProtocol { // Resume the syncing manager await syncingManager?.resume() + // Re-check stale state in case the user restored on another device while this one was backgrounded + let foregroundInboxId = result.client.inboxId + let foregroundIsActive: Bool? + do { + foregroundIsActive = try await result.client.isInstallationActive() + } catch { + Log.warning("[Stale] inbox \(foregroundInboxId): foreground check failed (non-fatal): \(error)") + foregroundIsActive = nil + } + if let foregroundIsActive { + let writer = InboxWriter(dbWriter: databaseWriter) + if foregroundIsActive { + Log.info("[Stale] inbox \(foregroundInboxId): foreground check — installation is active ✓") + try? await writer.markStale(inboxId: foregroundInboxId, false) + } else { + Log.warning("[Stale] inbox \(foregroundInboxId): foreground check — installation NOT in active list, marking stale") + try? await writer.markStale(inboxId: foregroundInboxId, true) + QAEvent.emit(.inbox, "stale_detected", ["inboxId": foregroundInboxId, "trigger": "foreground"]) + } + } + emitStateChange(.ready(clientId: clientId, result: result)) Log.info("Inbox returned to ready state") } diff --git a/ConvosCore/Sources/ConvosCore/Invites & Custom Metadata/XMTPGroup+CustomMetadata.swift b/ConvosCore/Sources/ConvosCore/Invites & Custom Metadata/XMTPGroup+CustomMetadata.swift index d584b5882..28b520559 100644 --- a/ConvosCore/Sources/ConvosCore/Invites & Custom Metadata/XMTPGroup+CustomMetadata.swift +++ b/ConvosCore/Sources/ConvosCore/Invites & Custom Metadata/XMTPGroup+CustomMetadata.swift @@ -132,7 +132,7 @@ extension XMTPiOS.Group { /// Clears the invite tag, preventing any new join requests from being accepted. public func clearInviteTag() async throws { - try await atomicUpdateMetadata { metadata in + try await atomicUpdateMetadata(operation: "clearInviteTag") { metadata in metadata.tag = "" } verify: { metadata in metadata.tag.isEmpty diff --git a/ConvosCore/Sources/ConvosCore/Logging/QAEvent.swift b/ConvosCore/Sources/ConvosCore/Logging/QAEvent.swift index e591228a7..3525c8cfc 100644 --- a/ConvosCore/Sources/ConvosCore/Logging/QAEvent.swift +++ b/ConvosCore/Sources/ConvosCore/Logging/QAEvent.swift @@ -4,6 +4,7 @@ public enum QAEvent { public enum Category: String { case app case conversation + case inbox case invite case member case message diff --git a/ConvosCore/Sources/ConvosCore/Messaging/UnusedConversationCache.swift b/ConvosCore/Sources/ConvosCore/Messaging/UnusedConversationCache.swift index 022a56fc6..9dc183d64 100644 --- a/ConvosCore/Sources/ConvosCore/Messaging/UnusedConversationCache.swift +++ b/ConvosCore/Sources/ConvosCore/Messaging/UnusedConversationCache.swift @@ -1066,7 +1066,8 @@ extension UnusedConversationCache { isUnread: false, isUnreadUpdatedAt: Date.distantPast, isMuted: false, - pinnedOrder: nil + pinnedOrder: nil, + isActive: true ) try localState.save(db) @@ -1170,7 +1171,8 @@ extension UnusedConversationCache { isUnread: false, isUnreadUpdatedAt: Date.distantPast, isMuted: false, - pinnedOrder: nil + pinnedOrder: nil, + isActive: true ) try localState.save(db) diff --git a/ConvosCore/Sources/ConvosCore/Messaging/XMTPClientProvider.swift b/ConvosCore/Sources/ConvosCore/Messaging/XMTPClientProvider.swift index b6fe53d64..f1a6ded10 100644 --- a/ConvosCore/Sources/ConvosCore/Messaging/XMTPClientProvider.swift +++ b/ConvosCore/Sources/ConvosCore/Messaging/XMTPClientProvider.swift @@ -142,6 +142,8 @@ public protocol XMTPClientProvider: AnyObject { func revokeInstallations( signingKey: SigningKey, installationIds: [String] ) async throws + func revokeAllOtherInstallations(signingKey: SigningKey) async throws + func isInstallationActive() async throws -> Bool func requestDeviceSync() async throws func deleteLocalDatabase() throws func reconnectLocalDatabase() async throws @@ -250,6 +252,11 @@ extension XMTPiOS.Client: XMTPClientProvider { public func requestDeviceSync() async throws { try await sendSyncRequest() } + + public func isInstallationActive() async throws -> Bool { + let state = try await inboxState(refreshFromNetwork: true) + return state.installations.contains { $0.id == installationID } + } } extension XMTPiOS.Conversation: MessageSender { diff --git a/ConvosCore/Sources/ConvosCore/Mocks/MockConversationLocalStateWriter.swift b/ConvosCore/Sources/ConvosCore/Mocks/MockConversationLocalStateWriter.swift index b48dbfe17..e33d8c295 100644 --- a/ConvosCore/Sources/ConvosCore/Mocks/MockConversationLocalStateWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Mocks/MockConversationLocalStateWriter.swift @@ -5,6 +5,8 @@ public final class MockConversationLocalStateWriter: ConversationLocalStateWrite public var unreadStates: [String: Bool] = [:] public var pinnedStates: [String: Bool] = [:] public var mutedStates: [String: Bool] = [:] + public var activeStates: [String: Bool] = [:] + public var markAllInactiveCalled: Bool = false public init() {} @@ -19,4 +21,12 @@ public final class MockConversationLocalStateWriter: ConversationLocalStateWrite public func setMuted(_ isMuted: Bool, for conversationId: String) async throws { mutedStates[conversationId] = isMuted } + + public func setActive(_ isActive: Bool, for conversationId: String) async throws { + activeStates[conversationId] = isActive + } + + public func markAllConversationsInactive() async throws { + markAllInactiveCalled = true + } } diff --git a/ConvosCore/Sources/ConvosCore/Mocks/MockXMTPClientProvider.swift b/ConvosCore/Sources/ConvosCore/Mocks/MockXMTPClientProvider.swift index aad7d6ed6..5d378eaee 100644 --- a/ConvosCore/Sources/ConvosCore/Mocks/MockXMTPClientProvider.swift +++ b/ConvosCore/Sources/ConvosCore/Mocks/MockXMTPClientProvider.swift @@ -69,6 +69,14 @@ public final class MockXMTPClientProvider: XMTPClientProvider, @unchecked Sendab // No-op for mock } + public func revokeAllOtherInstallations(signingKey: any SigningKey) async throws { + // No-op for mock + } + + public func isInstallationActive() async throws -> Bool { + true + } + public func requestDeviceSync() async throws { // No-op for mock } diff --git a/ConvosCore/Sources/ConvosCore/Mocks/ModelMocks.swift b/ConvosCore/Sources/ConvosCore/Mocks/ModelMocks.swift index 7079362d0..cb881a743 100644 --- a/ConvosCore/Sources/ConvosCore/Mocks/ModelMocks.swift +++ b/ConvosCore/Sources/ConvosCore/Mocks/ModelMocks.swift @@ -54,7 +54,8 @@ public extension Conversation { expiresAt: nil, debugInfo: ConversationDebugInfo.empty, isLocked: false, - assistantJoinStatus: nil + assistantJoinStatus: nil, + isActive: true ) } @@ -99,7 +100,8 @@ public extension Conversation { expiresAt: .distantFuture, debugInfo: .empty, isLocked: false, - assistantJoinStatus: nil + assistantJoinStatus: nil, + isActive: true ) } } @@ -225,7 +227,8 @@ public extension ConversationUpdate { creator: creator ?? .mock(isCurrentUser: false, name: "Alice"), addedMembers: addedMembers.isEmpty ? [.mock(isCurrentUser: false, name: "Bob")] : addedMembers, removedMembers: removedMembers, - metadataChanges: [] + metadataChanges: [], + isReconnection: false ) } } @@ -239,6 +242,9 @@ public extension ConversationUpdate { } else if !addedMembers.isEmpty { if addedMembers.count == 1, let member = addedMembers.first, member.isCurrentUser { + if isReconnection { + return "Reconnected" + } let asString = "as \(member.profile.displayName)" return "You joined \(asString)" } diff --git a/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift b/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift index 1d9c92bb8..67877deb7 100644 --- a/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift +++ b/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift @@ -704,3 +704,37 @@ extension SessionManager: VaultEventHandler { } } } + +// MARK: - RestoreLifecycleControlling + +extension SessionManager: RestoreLifecycleControlling { + public func prepareForRestore() async { + Log.info("[Restore] prepareForRestore: pausing import drainer") + await importSyncDrainer.pause() + Log.info("[Restore] prepareForRestore: stopping sleeping inbox checker") + await sleepingInboxChecker.stopPeriodicChecks() + Log.info("[Restore] prepareForRestore: stopping all inboxes") + await lifecycleManager.stopAll() + Log.info("[Restore] prepareForRestore: pausing vault") + await vaultService?.pauseVault() + unusedInboxPrepTask?.cancel() + unusedInboxPrepTask = nil + Log.info("[Restore] prepareForRestore: done") + } + + public func finishRestore() async { + Log.info("[Restore] finishRestore: resuming vault") + await vaultService?.resumeVault() + Log.info("[Restore] finishRestore: resuming import drainer") + await importSyncDrainer.resume() + Log.info("[Restore] finishRestore: starting sleeping inbox checker") + await sleepingInboxChecker.startPeriodicChecks() + Log.info("[Restore] finishRestore: scheduling unused inbox prep") + unusedInboxPrepTask = Task(priority: .background) { [weak self] in + guard let self, !Task.isCancelled else { return } + await self.lifecycleManager.prepareUnusedConversationIfNeeded() + } + notificationChangeReporter.notifyChangesInDatabase() + Log.info("[Restore] finishRestore: done") + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/ConversationLocalState.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/ConversationLocalState.swift index 89be80dae..ad53fd03c 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Database Models/ConversationLocalState.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/ConversationLocalState.swift @@ -13,6 +13,7 @@ struct ConversationLocalState: Codable, FetchableRecord, PersistableRecord, Hash static let isUnreadUpdatedAt: Column = Column(CodingKeys.isUnreadUpdatedAt) static let isMuted: Column = Column(CodingKeys.isMuted) static let pinnedOrder: Column = Column(CodingKeys.pinnedOrder) + static let isActive: Column = Column(CodingKeys.isActive) } let conversationId: String @@ -21,6 +22,7 @@ struct ConversationLocalState: Codable, FetchableRecord, PersistableRecord, Hash let isUnreadUpdatedAt: Date let isMuted: Bool let pinnedOrder: Int? + var isActive: Bool static let conversationForeignKey: ForeignKey = ForeignKey([Columns.conversationId], to: [DBConversation.Columns.id]) @@ -38,7 +40,8 @@ extension ConversationLocalState { isUnread: isUnread, isUnreadUpdatedAt: !isUnread ? Date() : (isUnread != self.isUnread ? Date() : isUnreadUpdatedAt), isMuted: isMuted, - pinnedOrder: pinnedOrder + pinnedOrder: pinnedOrder, + isActive: isActive ) } func with(isPinned: Bool) -> Self { @@ -48,7 +51,8 @@ extension ConversationLocalState { isUnread: isUnread, isUnreadUpdatedAt: isUnreadUpdatedAt, isMuted: isMuted, - pinnedOrder: pinnedOrder + pinnedOrder: pinnedOrder, + isActive: isActive ) } func with(isMuted: Bool) -> Self { @@ -58,7 +62,8 @@ extension ConversationLocalState { isUnread: isUnread, isUnreadUpdatedAt: isUnreadUpdatedAt, isMuted: isMuted, - pinnedOrder: pinnedOrder + pinnedOrder: pinnedOrder, + isActive: isActive ) } func with(pinnedOrder: Int?) -> Self { @@ -68,7 +73,19 @@ extension ConversationLocalState { isUnread: isUnread, isUnreadUpdatedAt: isUnreadUpdatedAt, isMuted: isMuted, - pinnedOrder: pinnedOrder + pinnedOrder: pinnedOrder, + isActive: isActive + ) + } + func with(isActive: Bool) -> Self { + .init( + conversationId: conversationId, + isPinned: isPinned, + isUnread: isUnread, + isUnreadUpdatedAt: isUnreadUpdatedAt, + isMuted: isMuted, + pinnedOrder: pinnedOrder, + isActive: isActive ) } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBInbox.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBInbox.swift index b8e02628a..548b25c39 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBInbox.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBInbox.swift @@ -19,6 +19,8 @@ struct DBInbox: Codable, FetchableRecord, PersistableRecord, Identifiable, Hasha static let sharedToVault: Column = Column(CodingKeys.sharedToVault) static let vaultSyncState: Column = Column(CodingKeys.vaultSyncState) static let vaultSyncAttempts: Column = Column(CodingKeys.vaultSyncAttempts) + static let installationId: Column = Column(CodingKeys.installationId) + static let isStale: Column = Column(CodingKeys.isStale) } var id: String { inboxId } @@ -29,6 +31,8 @@ struct DBInbox: Codable, FetchableRecord, PersistableRecord, Identifiable, Hasha var sharedToVault: Bool var vaultSyncState: VaultSyncState var vaultSyncAttempts: Int + var installationId: String? + var isStale: Bool init( inboxId: String, @@ -37,7 +41,9 @@ struct DBInbox: Codable, FetchableRecord, PersistableRecord, Identifiable, Hasha isVault: Bool = false, sharedToVault: Bool = false, vaultSyncState: VaultSyncState = .none, - vaultSyncAttempts: Int = 0 + vaultSyncAttempts: Int = 0, + installationId: String? = nil, + isStale: Bool = false ) { self.inboxId = inboxId self.clientId = clientId @@ -46,6 +52,8 @@ struct DBInbox: Codable, FetchableRecord, PersistableRecord, Identifiable, Hasha self.sharedToVault = sharedToVault self.vaultSyncState = vaultSyncState self.vaultSyncAttempts = vaultSyncAttempts + self.installationId = installationId + self.isStale = isStale } static let conversations: HasManyAssociation = hasMany( diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMessage.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMessage.swift index 8ea9828df..e8ce34154 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMessage.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMessage.swift @@ -18,6 +18,33 @@ struct DBMessage: FetchableRecord, PersistableRecord, Hashable, Codable, Sendabl let removedInboxIds: [String] let metadataChanges: [MetadataChange] let expiresAt: Date? + var isReconnection: Bool + + init( + initiatedByInboxId: String, + addedInboxIds: [String], + removedInboxIds: [String], + metadataChanges: [MetadataChange], + expiresAt: Date?, + isReconnection: Bool = false + ) { + self.initiatedByInboxId = initiatedByInboxId + self.addedInboxIds = addedInboxIds + self.removedInboxIds = removedInboxIds + self.metadataChanges = metadataChanges + self.expiresAt = expiresAt + self.isReconnection = isReconnection + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + initiatedByInboxId = try container.decode(String.self, forKey: .initiatedByInboxId) + addedInboxIds = try container.decode([String].self, forKey: .addedInboxIds) + removedInboxIds = try container.decode([String].self, forKey: .removedInboxIds) + metadataChanges = try container.decode([MetadataChange].self, forKey: .metadataChanges) + expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt) + isReconnection = try container.decodeIfPresent(Bool.self, forKey: .isReconnection) ?? false + } } enum Columns { @@ -291,6 +318,28 @@ extension DBMessage { ) } + func with(update: Update?) -> DBMessage { + .init( + id: id, + clientMessageId: clientMessageId, + conversationId: conversationId, + senderId: senderId, + dateNs: dateNs, + date: date, + sortId: sortId, + status: status, + messageType: messageType, + contentType: contentType, + text: text, + emoji: emoji, + invite: invite, + linkPreview: linkPreview, + sourceMessageId: sourceMessageId, + attachmentUrls: attachmentUrls, + update: update + ) + } + var hasLocalAttachments: Bool { attachmentUrls.contains { $0.hasPrefix("file://") } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/DatabaseManager.swift b/ConvosCore/Sources/ConvosCore/Storage/DatabaseManager.swift index a0987aae7..859d75332 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/DatabaseManager.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/DatabaseManager.swift @@ -2,9 +2,10 @@ import Foundation import GRDB import SQLite3 -public protocol DatabaseManagerProtocol { +public protocol DatabaseManagerProtocol: Sendable { var dbWriter: DatabaseWriter { get } var dbReader: DatabaseReader { get } + func replaceDatabase(with backupPath: URL) throws } /// Manages the SQLite database for Convos @@ -14,10 +15,10 @@ public protocol DatabaseManagerProtocol { /// is stored in the shared App Group container to enable multi-process access. /// Configures connection pooling, busy timeouts, and persistent WAL mode for /// read-only processes. -public final class DatabaseManager: DatabaseManagerProtocol { +public final class DatabaseManager: DatabaseManagerProtocol, @unchecked Sendable { let environment: AppEnvironment - public let dbPool: DatabasePool + public private(set) var dbPool: DatabasePool public var dbWriter: DatabaseWriter { dbPool as DatabaseWriter @@ -36,6 +37,50 @@ public final class DatabaseManager: DatabaseManagerProtocol { } } + /// Replaces the current database with a backup copy. + /// + /// This is a destructive operation intended for restore scenarios only. + /// The existing `DatabasePool` instance is preserved so any long-lived + /// readers and writers held elsewhere in the app remain valid after restore. + /// If the replacement fails, the original database contents are restored. + public func replaceDatabase(with backupPath: URL) throws { + guard FileManager.default.fileExists(atPath: backupPath.path) else { + throw CocoaError(.fileNoSuchFile) + } + + Log.info("[Restore] opening backup at: \(backupPath.lastPathComponent)") + let backupQueue = try DatabaseQueue(path: backupPath.path) + + Log.info("[Restore] creating rollback snapshot") + let rollbackQueue = try DatabaseQueue() + try dbPool.backup(to: rollbackQueue) + + Log.info("[Restore] copying backup into live pool") + do { + try backupQueue.backup(to: dbPool) + Log.info("[Restore] running migrations") + try SharedDatabaseMigrator.shared.migrate(database: dbPool) + Log.info("[Restore] database replacement succeeded") + } catch { + Log.warning("[Restore] replacement failed (\(error)), rolling back") + do { + try rollbackQueue.backup(to: dbPool) + try SharedDatabaseMigrator.shared.migrate(database: dbPool) + Log.info("[Restore] rollback succeeded") + } catch let rollbackError as Error { + // Rollback or its post-restore migration failed. The DB is in a + // potentially-inconsistent state — surface a dedicated error so + // the caller knows it's worse than just a failed restore. + Log.error("[Restore] rollback failed (original error: \(error)) — \(rollbackError)") + throw DatabaseManagerError.rollbackFailed( + original: error, + rollback: rollbackError + ) + } + throw error + } + } + private static func makeDatabasePool(environment: AppEnvironment) throws -> DatabasePool { let fileManager = FileManager.default // Use the shared App Group container so the main app and NSE share the same DB @@ -82,3 +127,17 @@ public final class DatabaseManager: DatabaseManagerProtocol { return dbPool } } + +public enum DatabaseManagerError: Error, LocalizedError { + /// The restore failed AND the rollback also failed. The DB is in a + /// potentially-inconsistent state — the caller should treat this as + /// recoverable only via app reinstall or a fresh restore attempt. + case rollbackFailed(original: any Error, rollback: any Error) + + public var errorDescription: String? { + switch self { + case let .rollbackFailed(original, rollback): + return "Database restore failed and rollback also failed. Original error: \(original.localizedDescription). Rollback error: \(rollback.localizedDescription)" + } + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBConversationDetails+Conversation.swift b/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBConversationDetails+Conversation.swift index dd3bd80e3..24dfd35ca 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBConversationDetails+Conversation.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBConversationDetails+Conversation.swift @@ -67,7 +67,8 @@ extension DBConversationDetails { expiresAt: conversation.expiresAt, debugInfo: conversation.debugInfo, isLocked: conversation.isLocked, - assistantJoinStatus: assistantJoinStatus + assistantJoinStatus: assistantJoinStatus, + isActive: conversationLocalState.isActive ) } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBInbox+Inbox.swift b/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBInbox+Inbox.swift index cde8dc47d..cd4c4c1fd 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBInbox+Inbox.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBInbox+Inbox.swift @@ -6,7 +6,9 @@ extension DBInbox { inboxId: inboxId, clientId: clientId, createdAt: createdAt, - isVault: isVault + isVault: isVault, + installationId: installationId, + isStale: isStale ) } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/MockDatabaseManager.swift b/ConvosCore/Sources/ConvosCore/Storage/MockDatabaseManager.swift index db64ac9ac..52e599875 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/MockDatabaseManager.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/MockDatabaseManager.swift @@ -20,6 +20,18 @@ final class MockDatabaseManager: DatabaseManagerProtocol, @unchecked Sendable { try SharedDatabaseMigrator.shared.migrate(database: dbPool) } + func replaceDatabase(with backupPath: URL) throws { + let backupQueue = try DatabaseQueue(path: backupPath.path) + let tempQueue = try DatabaseQueue() + try backupQueue.backup(to: tempQueue) + try dbPool.erase() + try tempQueue.backup(to: dbPool) + // Match DatabaseManager.replaceDatabase: run migrations after restore so + // tests catch schema-mismatch failures that would otherwise only surface + // in production. + try SharedDatabaseMigrator.shared.migrate(database: dbPool) + } + private init(migrate: Bool = true) { do { dbPool = try DatabaseQueue(named: "MockDatabase") diff --git a/ConvosCore/Sources/ConvosCore/Storage/Models/Conversation.swift b/ConvosCore/Sources/ConvosCore/Storage/Models/Conversation.swift index d028550be..ae96f88f6 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Models/Conversation.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Models/Conversation.swift @@ -32,6 +32,7 @@ public struct Conversation: Codable, Hashable, Identifiable, Sendable { public let debugInfo: ConversationDebugInfo public let isLocked: Bool public let assistantJoinStatus: AssistantJoinStatus? + public let isActive: Bool } public extension Conversation { diff --git a/ConvosCore/Sources/ConvosCore/Storage/Models/ConversationUpdate.swift b/ConvosCore/Sources/ConvosCore/Storage/Models/ConversationUpdate.swift index d2df4166b..8a21b14de 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Models/ConversationUpdate.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Models/ConversationUpdate.swift @@ -28,6 +28,7 @@ public struct ConversationUpdate: Hashable, Codable, Sendable { public let addedMembers: [ConversationMember] public let removedMembers: [ConversationMember] public let metadataChanges: [MetadataChange] + public let isReconnection: Bool public var profileMember: ConversationMember? { if !addedMembers.isEmpty && !removedMembers.isEmpty { diff --git a/ConvosCore/Sources/ConvosCore/Storage/Models/Inbox.swift b/ConvosCore/Sources/ConvosCore/Storage/Models/Inbox.swift index 075a6df2c..6c07decbc 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Models/Inbox.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Models/Inbox.swift @@ -6,11 +6,36 @@ public struct Inbox: Codable, Hashable, Identifiable { public let clientId: String public let createdAt: Date public let isVault: Bool + public let installationId: String? + public let isStale: Bool - public init(inboxId: String, clientId: String, createdAt: Date = Date(), isVault: Bool = false) { + public init( + inboxId: String, + clientId: String, + createdAt: Date = Date(), + isVault: Bool = false, + installationId: String? = nil, + isStale: Bool = false + ) { self.inboxId = inboxId self.clientId = clientId self.createdAt = createdAt self.isVault = isVault + self.installationId = installationId + self.isStale = isStale + } + + // Custom decoder keeps `isStale` backwards-compatible with any JSON + // payload written before the column was added. Swift's synthesized + // Decodable ignores default init parameter values, so without this + // override decoding old data would throw keyNotFound on `isStale`. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.inboxId = try container.decode(String.self, forKey: .inboxId) + self.clientId = try container.decode(String.self, forKey: .clientId) + self.createdAt = try container.decode(Date.self, forKey: .createdAt) + self.isVault = try container.decode(Bool.self, forKey: .isVault) + self.installationId = try container.decodeIfPresent(String.self, forKey: .installationId) + self.isStale = try container.decodeIfPresent(Bool.self, forKey: .isStale) ?? false } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Repositories/InboxesRepository.swift b/ConvosCore/Sources/ConvosCore/Storage/Repositories/InboxesRepository.swift index 9b173c4c7..0d51d53d0 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Repositories/InboxesRepository.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Repositories/InboxesRepository.swift @@ -1,6 +1,38 @@ +import Combine import Foundation import GRDB +/// Derived state for the local device's stale-installation status. +/// +/// Computed from the set of "used" non-vault inboxes (inboxes that have at least +/// one non-unused conversation) and how many of them are flagged as `isStale = true`. +/// +/// Used by the conversations list to drive partial vs full stale UX: +/// - `healthy` — no stale inboxes; normal operation +/// - `partialStale` — some inboxes are revoked but the user still has working ones +/// - `fullStale` — every used inbox has been revoked; the device is effectively dead +public enum StaleDeviceState: Equatable, Sendable { + case healthy + case partialStale + case fullStale + + /// True when the user has at least one working inbox (healthy or partial). + public var hasUsableInboxes: Bool { + switch self { + case .healthy, .partialStale: true + case .fullStale: false + } + } + + /// True when any inbox is stale (partial or full). + public var hasAnyStaleInboxes: Bool { + switch self { + case .healthy: false + case .partialStale, .fullStale: true + } + } +} + /// Repository for fetching inbox data from the database public struct InboxesRepository { private let databaseReader: any DatabaseReader @@ -9,6 +41,69 @@ public struct InboxesRepository { self.databaseReader = databaseReader } + /// Publishes the derived `StaleDeviceState` based on the current set of used non-vault inboxes + /// and which of them are flagged as stale. See `StaleDeviceState` for the semantics. + public func staleDeviceStatePublisher() -> AnyPublisher { + ValueObservation + .tracking { db in + let usedSql = """ + SELECT i.inboxId, i.isStale + FROM inbox i + WHERE i.isVault = 0 + AND EXISTS ( + SELECT 1 + FROM conversation c + WHERE c.inboxId = i.inboxId + AND c.isUnused = 0 + ) + """ + let rows = try Row.fetchAll(db, sql: usedSql) + let total = rows.count + let stale = rows.filter { $0["isStale"] as Bool == true }.count + + if total == 0 || stale == 0 { + return StaleDeviceState.healthy + } + if stale == total { + return StaleDeviceState.fullStale + } + return StaleDeviceState.partialStale + } + .publisher(in: databaseReader) + .replaceError(with: StaleDeviceState.healthy) + .eraseToAnyPublisher() + } + + /// Publishes `true` when any non-vault inbox is flagged as stale (installation revoked). + public func anyInboxStalePublisher() -> AnyPublisher { + ValueObservation + .tracking { db in + try DBInbox + .filter(DBInbox.Columns.isVault == false) + .filter(DBInbox.Columns.isStale == true) + .fetchCount(db) > 0 + } + .publisher(in: databaseReader) + .replaceError(with: false) + .eraseToAnyPublisher() + } + + /// Publishes the set of inboxIds that are currently stale. + public func staleInboxIdsPublisher() -> AnyPublisher, Never> { + ValueObservation + .tracking { db in + let ids = try DBInbox + .filter(DBInbox.Columns.isStale == true) + .filter(DBInbox.Columns.isVault == false) + .select(DBInbox.Columns.inboxId, as: String.self) + .fetchAll(db) + return Set(ids) + } + .publisher(in: databaseReader) + .replaceError(with: Set()) + .eraseToAnyPublisher() + } + /// Fetch all inboxes from the database public func allInboxes() throws -> [Inbox] { try databaseReader.read { db in @@ -54,4 +149,21 @@ public struct InboxesRepository { .map { $0.toDomain() } } } + + public func nonVaultUsedInboxes() throws -> [Inbox] { + try databaseReader.read { db in + let sql = """ + SELECT i.* + FROM inbox i + WHERE i.isVault = 0 + AND EXISTS ( + SELECT 1 + FROM conversation c + WHERE c.inboxId = i.inboxId + AND c.isUnused = 0 + ) + """ + return try DBInbox.fetchAll(db, sql: sql).map { $0.toDomain() } + } + } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift b/ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift index 9b6abb6f3..25fab3170 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift @@ -393,7 +393,8 @@ extension Array where Element == DBMessage { creator: initiatedByMember, addedMembers: addedMembers, removedMembers: removedMembers, - metadataChanges: metadataChanges + metadataChanges: metadataChanges, + isReconnection: update.isReconnection ) ) case .assistantJoinRequest: @@ -696,7 +697,8 @@ private extension LightweightConversationDetails { expiresAt: conversation.expiresAt, debugInfo: conversation.debugInfo, isLocked: conversation.isLocked, - assistantJoinStatus: nil + assistantJoinStatus: nil, + isActive: conversationLocalState.isActive ) } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift b/ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift index 267c9429e..ea52da11f 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift @@ -459,6 +459,25 @@ extension SharedDatabaseMigrator { t.add(column: "vaultSyncAttempts", .integer).notNull().defaults(to: 0) } } + + migrator.registerMigration("addInstallationIdToInbox") { db in + try db.alter(table: "inbox") { t in + t.add(column: "installationId", .text) + } + } + + migrator.registerMigration("addIsActiveToConversationLocalState") { db in + try db.alter(table: "conversationLocalState") { t in + t.add(column: "isActive", .boolean).notNull().defaults(to: true) + } + } + + migrator.registerMigration("addIsStaleToInbox") { db in + try db.alter(table: "inbox") { t in + t.add(column: "isStale", .boolean).notNull().defaults(to: false) + } + } + return migrator } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift index cb0bc7fc7..29f4a4a7c 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift @@ -5,6 +5,8 @@ public protocol ConversationLocalStateWriterProtocol: Sendable { func setUnread(_ isUnread: Bool, for conversationId: String) async throws func setPinned(_ isPinned: Bool, for conversationId: String) async throws func setMuted(_ isMuted: Bool, for conversationId: String) async throws + func setActive(_ isActive: Bool, for conversationId: String) async throws + func markAllConversationsInactive() async throws } /// @unchecked Sendable: GRDB's DatabaseWriter provides thread-safe access via write{} @@ -50,7 +52,8 @@ final class ConversationLocalStateWriter: ConversationLocalStateWriterProtocol, isUnread: false, isUnreadUpdatedAt: Date(), isMuted: false, - pinnedOrder: nil + pinnedOrder: nil, + isActive: true ) let pinnedOrder: Int? = if isPinned { @@ -79,6 +82,20 @@ final class ConversationLocalStateWriter: ConversationLocalStateWriterProtocol, QAEvent.emit(.conversation, isMuted ? "muted" : "unmuted", ["id": conversationId]) } + func setActive(_ isActive: Bool, for conversationId: String) async throws { + try await updateLocalState(for: conversationId) { state in + state.with(isActive: isActive) + } + } + + func markAllConversationsInactive() async throws { + try await databaseWriter.write { db in + try db.execute( + sql: "UPDATE conversationLocalState SET isActive = 0" + ) + } + } + private func updateLocalState( for conversationId: String, _ update: @escaping @Sendable (ConversationLocalState) -> ConversationLocalState @@ -97,7 +114,8 @@ final class ConversationLocalStateWriter: ConversationLocalStateWriterProtocol, isUnread: false, isUnreadUpdatedAt: Date(), isMuted: false, - pinnedOrder: nil + pinnedOrder: nil, + isActive: true ) let updated = update(current) try updated.save(db) diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift index bb8449de2..cecef0345 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift @@ -176,7 +176,8 @@ class ConversationWriter: ConversationWriterProtocol, @unchecked Sendable { isUnread: false, isUnreadUpdatedAt: Date(), isMuted: false, - pinnedOrder: nil + pinnedOrder: nil, + isActive: true ) try localState.save(db) @@ -390,7 +391,8 @@ class ConversationWriter: ConversationWriterProtocol, @unchecked Sendable { isUnread: false, isUnreadUpdatedAt: Date.distantPast, isMuted: false, - pinnedOrder: nil + pinnedOrder: nil, + isActive: true ) try localState.insert(db, onConflict: .ignore) @@ -406,14 +408,21 @@ class ConversationWriter: ConversationWriterProtocol, @unchecked Sendable { // Save members (upserts conversation_members + stub memberProfile rows) try self.saveMembers(dbMembers, in: db) - // Fill gaps: only write appData profiles for members without message-sourced data + // Fill gaps: write appData profiles for members without message-sourced data. + // After restore (isActive == false), force-apply metadata profiles to re-adopt + // names and avatars from XMTP group metadata. + let isInactive = try ConversationLocalState + .filter(ConversationLocalState.Columns.conversationId == dbConversation.id) + .filter(ConversationLocalState.Columns.isActive == false) + .fetchOne(db) != nil + try memberProfiles.forEach { profile in let existing = try DBMemberProfile.fetchOne( db, conversationId: dbConversation.id, inboxId: profile.inboxId ) - if existing?.name != nil || existing?.avatar != nil || existing?.memberKind != nil { + if !isInactive, existing?.name != nil || existing?.avatar != nil || existing?.memberKind != nil { return } let member = DBMember(inboxId: profile.inboxId) diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/InboxWriter.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/InboxWriter.swift index f96ed6784..e28cab2ea 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/InboxWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/InboxWriter.swift @@ -32,7 +32,7 @@ struct InboxWriter { } @discardableResult - func save(inboxId: String, clientId: String, isVault: Bool = false) async throws -> DBInbox { + func save(inboxId: String, clientId: String, isVault: Bool = false, installationId: String? = nil) async throws -> DBInbox { try await dbWriter.write { db in if isVault { let existingVault = try DBInbox @@ -44,20 +44,43 @@ struct InboxWriter { } if let existingInbox = try DBInbox.fetchOne(db, id: inboxId) { + var currentInbox = existingInbox + if existingInbox.clientId != clientId { - Log.error(""" - ClientId mismatch detected! - InboxId: \(inboxId) - Existing clientId: \(existingInbox.clientId) - Attempted clientId: \(clientId) - """) - throw InboxWriterError.clientIdMismatch( - inboxId: inboxId, - existingClientId: existingInbox.clientId, - newClientId: clientId - ) + if isVault { + Log.info("Vault clientId changed (new installation): \(existingInbox.clientId) → \(clientId)") + try db.execute( + sql: "UPDATE inbox SET clientId = ? WHERE inboxId = ?", + arguments: [clientId, inboxId] + ) + currentInbox = try DBInbox.fetchOne(db, id: inboxId) ?? existingInbox + } else { + Log.error(""" + ClientId mismatch detected! + InboxId: \(inboxId) + Existing clientId: \(existingInbox.clientId) + Attempted clientId: \(clientId) + """) + throw InboxWriterError.clientIdMismatch( + inboxId: inboxId, + existingClientId: existingInbox.clientId, + newClientId: clientId + ) + } + } + + guard let installationId else { + return currentInbox + } + + guard currentInbox.installationId != installationId else { + return currentInbox } - return existingInbox + + var updatedInbox = currentInbox + updatedInbox.installationId = installationId + try updatedInbox.update(db) + return updatedInbox } let inbox = DBInbox( @@ -85,9 +108,54 @@ struct InboxWriter { } } + func markStale(inboxId: String, _ isStale: Bool = true) async throws { + try await dbWriter.write { db in + try db.execute( + sql: "UPDATE inbox SET isStale = ? WHERE inboxId = ?", + arguments: [isStale, inboxId] + ) + } + } + func deleteAll() async throws { - _ = try await dbWriter.write { db in - try DBInbox.deleteAll(db) + try await dbWriter.write { db in + // Delete children before parents to respect foreign keys. + // + // Dependency graph (child → parent): + // message → conversation + // attachmentLocalState → conversation + // conversationLocalState → conversation + // invite → conversation_members (FK: creatorInboxId + conversationId) + // conversation_members → conversation, member + // memberProfile → conversation, member + // conversation → (no app-level parents) + // member → (no app-level parents) + // inbox, vaultDevice, photoPreferences, pendingPhotoUpload → independent + // + // Cascades are set on FKs, but we still delete children first for + // defensive safety if cascades are ever removed. + let tables = [ + "message", + "attachmentLocalState", + "conversationLocalState", + "invite", + "conversation_members", + "memberProfile", + "photoPreferences", + "pendingPhotoUpload", + "conversation", + "member", + "vaultDevice", + "inbox", + ] + for table in tables { + do { + try db.execute(sql: "DELETE FROM \(table)") + } catch { + Log.error("deleteAll: failed to delete from \(table): \(error)") + throw error + } + } } } } diff --git a/ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift b/ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift index 4a8a454f8..d4efc53cb 100644 --- a/ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift +++ b/ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift @@ -28,9 +28,19 @@ protocol StreamProcessorProtocol: Actor { func setInviteJoinErrorHandler(_ handler: (any InviteJoinErrorHandler)?) func setTypingIndicatorHandler(_ handler: @escaping @Sendable (String, String, Bool) -> Void) + + /// Reactivate any conversations that are present in the XMTP client's + /// group list but still marked inactive in the local DB. Called after the + /// initial sync completes to handle post-restore conversations that would + /// otherwise stay "Awaiting reconnection" until a new message arrives. + func reactivateRestoredConversations(knownGroupIds: Set) async } extension StreamProcessorProtocol { + func reactivateRestoredConversations(knownGroupIds: Set) async { + // Default no-op for conformers that don't handle post-restore reactivation. + } + func processConversation( _ conversation: XMTPiOS.Group, params: SyncClientParams @@ -158,7 +168,7 @@ actor StreamProcessor: StreamProcessorProtocol { let perfStart = CFAbsoluteTimeGetCurrent() Log.info("Syncing conversation: \(conversation.id)") - try await conversationWriter.storeWithLatestMessages( + let dbConversation = try await conversationWriter.storeWithLatestMessages( conversation: conversation, inboxId: params.client.inboxId, clientConversationId: clientConversationId @@ -166,6 +176,8 @@ actor StreamProcessor: StreamProcessorProtocol { let perfElapsed = String(format: "%.0f", (CFAbsoluteTimeGetCurrent() - perfStart) * 1000) Log.info("[PERF] conversation.sync: \(perfElapsed)ms id=\(conversation.id)") + await reactivateIfNeeded(conversationId: dbConversation.id) + if creatorInboxId == params.client.inboxId { await sendInitialProfileSnapshot(group: conversation) } @@ -217,10 +229,23 @@ actor StreamProcessor: StreamProcessorProtocol { return } - let dbConversation = try await conversationWriter.store( - conversation: conversation, - inboxId: params.client.inboxId - ) + let dbConversation: DBConversation + do { + dbConversation = try await conversationWriter.store( + conversation: conversation, + inboxId: params.client.inboxId + ) + } catch is CancellationError { + throw CancellationError() + } catch { + Log.warning("conversationWriter.store failed, falling back to existing DBConversation: \(error)") + guard let existing = try await databaseReader.read({ db in + try DBConversation.fetchOne(db, id: conversation.id) + }) else { + throw error + } + dbConversation = existing + } // Handle ExplodeSettings - skip storing message if this is an explode message let explodeSettings = messageWriter.decodeExplodeSettings(from: message) @@ -244,6 +269,11 @@ actor StreamProcessor: StreamProcessorProtocol { let result = try await messageWriter.store(message: message, for: dbConversation) + await markReconnectionIfNeeded( + messageId: message.id, + conversationId: conversation.id + ) + // Mark unread if needed if result.contentType.marksConversationAsUnread, conversation.id != activeConversationId, @@ -253,10 +283,22 @@ actor StreamProcessor: StreamProcessorProtocol { let perfElapsed = String(format: "%.0f", (CFAbsoluteTimeGetCurrent() - perfStart) * 1000) Log.info("[PERF] message.process: \(perfElapsed)ms id=\(message.id)") + } catch is CancellationError { + // This function is `async` (not `async throws`), so we + // cannot rethrow. Log and return early — the enclosing + // stream loop calls `try Task.checkCancellation()` at + // the top of every iteration, so the task exits on the + // next message. One extra in-flight message is acceptable + // for cooperative cancellation here. + Log.debug("Group message processing cancelled") + return } catch { Log.error("Failed processing group message: \(error.localizedDescription)") } } + } catch is CancellationError { + Log.debug("Message processing cancelled") + return } catch { Log.warning("Stopped processing message from error: \(error.localizedDescription)") } @@ -279,6 +321,95 @@ actor StreamProcessor: StreamProcessorProtocol { await inviteJoinErrorHandler?.handleInviteJoinError(error) } + // MARK: - Reactivation + + func reactivateRestoredConversations(knownGroupIds: Set) async { + guard !knownGroupIds.isEmpty else { return } + do { + let inactiveIds: [String] = try await databaseReader.read { db in + try String.fetchAll(db, sql: """ + SELECT conversationId FROM conversationLocalState WHERE isActive = 0 + """) + } + let toReactivate = inactiveIds.filter { knownGroupIds.contains($0) } + guard !toReactivate.isEmpty else { return } + + for conversationId in toReactivate { + try await markRecentUpdatesAsReconnection(conversationId: conversationId) + try await localStateWriter.setActive(true, for: conversationId) + } + Log.info("Reactivated \(toReactivate.count) restored conversation(s) confirmed by XMTP sync") + } catch { + Log.warning("reactivateRestoredConversations failed: \(error)") + } + } + + private func reactivateIfNeeded(conversationId: String) async { + do { + let isInactive = try await databaseReader.read { db in + try ConversationLocalState + .filter(ConversationLocalState.Columns.conversationId == conversationId) + .filter(ConversationLocalState.Columns.isActive == false) + .fetchOne(db) != nil + } + guard isInactive else { return } + + try await markRecentUpdatesAsReconnection(conversationId: conversationId) + try await localStateWriter.setActive(true, for: conversationId) + Log.info("Reactivated conversation \(conversationId) during sync") + } catch { + Log.warning("reactivateIfNeeded failed for \(conversationId): \(error)") + } + } + + private func markRecentUpdatesAsReconnection(conversationId: String) async throws { + try await databaseWriter.write { db in + let sql = """ + SELECT id FROM message + WHERE conversationId = ? + AND contentType = 'update' + ORDER BY date DESC + LIMIT 5 + """ + let messageIds = try String.fetchAll(db, sql: sql, arguments: [conversationId]) + for messageId in messageIds { + guard var dbMessage = try DBMessage.fetchOne(db, key: messageId), + var update = dbMessage.update else { continue } + if !update.isReconnection { + update.isReconnection = true + dbMessage = dbMessage.with(update: update) + try dbMessage.save(db) + } + } + } + } + + private func markReconnectionIfNeeded(messageId: String, conversationId: String) async { + do { + let isInactive = try await databaseReader.read { db in + try ConversationLocalState + .filter(ConversationLocalState.Columns.conversationId == conversationId) + .filter(ConversationLocalState.Columns.isActive == false) + .fetchOne(db) != nil + } + guard isInactive else { return } + + try await databaseWriter.write { db in + if var dbMessage = try DBMessage.fetchOne(db, key: messageId), + var update = dbMessage.update { + update.isReconnection = true + dbMessage = dbMessage.with(update: update) + try dbMessage.save(db) + } + } + + try await localStateWriter.setActive(true, for: conversationId) + Log.info("Reactivated conversation \(conversationId) after receiving message") + } catch { + Log.warning("markReconnectionIfNeeded failed for \(conversationId): \(error)") + } + } + // MARK: - Profile Messages private func processTypingIndicator( diff --git a/ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift b/ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift index c5483bdd8..78fe831d3 100644 --- a/ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift +++ b/ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift @@ -461,6 +461,16 @@ actor SyncingManager: SyncingManagerProtocol { if discoveredCount > 0 { Log.info("Discovered \(discoveredCount) new conversations after sync") } + + // After restore, conversations are marked inactive ("Awaiting + // reconnection") and only reactivate on message receipt. But + // quiet conversations would stay inactive indefinitely because + // discoverNewConversations skips groups already in the DB. + // Pass the full set of XMTP-confirmed group IDs so the stream + // processor can flip any inactive conversations whose groups + // were successfully synced. + let allGroupIds = Set(groups.map(\.id)) + await streamProcessor.reactivateRestoredConversations(knownGroupIds: allGroupIds) } catch { Log.error("Failed to discover new conversations: \(error)") } diff --git a/ConvosCore/Sources/ConvosCore/Vault/VaultKeyCoordinator.swift b/ConvosCore/Sources/ConvosCore/Vault/VaultKeyCoordinator.swift index d86b808e6..ba90a630c 100644 --- a/ConvosCore/Sources/ConvosCore/Vault/VaultKeyCoordinator.swift +++ b/ConvosCore/Sources/ConvosCore/Vault/VaultKeyCoordinator.swift @@ -92,7 +92,9 @@ actor VaultKeyCoordinator { } func shareAllKeys(pendingPeerDeviceNames: [String: String]) async throws { + Log.info("[VaultKeyCoordinator.shareAllKeys] === START ===") guard let installationId = await vaultClient.installationId else { + Log.error("[VaultKeyCoordinator.shareAllKeys] vault client not connected, aborting") throw VaultClientError.notConnected } @@ -113,10 +115,16 @@ actor VaultKeyCoordinator { ) } } + Log.info("[VaultKeyCoordinator.shareAllKeys] found \(inboxRows.count) (inbox, conversation) row(s) to share") var keys: [DeviceKeyEntry] = [] + var missingIdentityCount = 0 for item in inboxRows { - guard let identity = try? await identityStore.identity(for: item.inboxId) else { continue } + guard let identity = try? await identityStore.identity(for: item.inboxId) else { + missingIdentityCount += 1 + Log.warning("[VaultKeyCoordinator.shareAllKeys] missing keychain identity for inboxId=\(item.inboxId), skipping") + continue + } keys.append(DeviceKeyEntry( conversationId: item.conversationId, inboxId: item.inboxId, @@ -125,6 +133,7 @@ actor VaultKeyCoordinator { databaseKey: identity.keys.databaseKey )) } + Log.info("[VaultKeyCoordinator.shareAllKeys] packaged \(keys.count) key(s) into bundle (skipped \(missingIdentityCount) due to missing identity)") let peerNames = pendingPeerDeviceNames.isEmpty ? nil : pendingPeerDeviceNames let bundle = DeviceKeyBundleContent( @@ -134,18 +143,25 @@ actor VaultKeyCoordinator { peerDeviceNames: peerNames ) + Log.info("[VaultKeyCoordinator.shareAllKeys] sending DeviceKeyBundle to vault group (installationId=\(installationId) deviceName=\(deviceName))") try await vaultClient.send(bundle, codec: DeviceKeyBundleCodec()) - if let databaseWriter, !inboxRows.isEmpty { - let inboxIds = inboxRows.map { $0.inboxId } + Log.info("[VaultKeyCoordinator.shareAllKeys] DeviceKeyBundle sent successfully") + // Only mark inboxes whose keys were actually packaged into the bundle. + // Inboxes skipped due to missing keychain identity must NOT be marked + // shared, otherwise they will never be re-attempted on future syncs. + let sharedInboxIds = keys.map { $0.inboxId } + if let databaseWriter, !sharedInboxIds.isEmpty { try? await databaseWriter.write { db in - for id in inboxIds { + for id in sharedInboxIds { try db.execute( sql: "UPDATE inbox SET sharedToVault = 1 WHERE inboxId = ?", arguments: [id] ) } } + Log.info("[VaultKeyCoordinator.shareAllKeys] marked \(sharedInboxIds.count) inbox(es) as sharedToVault=1") } + Log.info("[VaultKeyCoordinator.shareAllKeys] === DONE ===") } func checkUnsharedInboxes() async { diff --git a/ConvosCore/Sources/ConvosCore/Vault/VaultManager.swift b/ConvosCore/Sources/ConvosCore/Vault/VaultManager.swift index 4671474f9..fa965cfae 100644 --- a/ConvosCore/Sources/ConvosCore/Vault/VaultManager.swift +++ b/ConvosCore/Sources/ConvosCore/Vault/VaultManager.swift @@ -122,7 +122,9 @@ public actor VaultManager { } do { + Log.info("[Vault.bootstrap] starting, loading or creating vault identity") let identity = try await loadOrCreateVaultIdentity(vaultKeyStore: vaultKeyStore) + Log.info("[Vault.bootstrap] identity loaded: inboxId=\(identity.inboxId) clientId=\(identity.clientId)") let signingKey = identity.keys.signingKey let api = XMTPAPIOptionsBuilder.build(environment: environment) @@ -140,6 +142,7 @@ public actor VaultManager { dbEncryptionKey: identity.keys.databaseKey ) + Log.info("[Vault.bootstrap] connecting XMTP client for signing key identity=\(signingKey.identity.identifier)") try await connect(signingKey: signingKey, options: options) guard let inboxId = await vaultInboxId, @@ -147,19 +150,77 @@ public actor VaultManager { throw VaultClientError.notConnected } - if identity.inboxId == "vault-pending" { - try? await vaultKeyStore.delete(inboxId: "vault-pending") - _ = try? await vaultKeyStore.save( - inboxId: inboxId, - clientId: installationId, - keys: identity.keys - ) + Log.info("[Vault.bootstrap] XMTP client connected: inboxId=\(inboxId) installationId=\(installationId)") + + if identity.inboxId == "vault-pending" || identity.clientId != installationId { + // Save the new/updated entry first, then delete the old one. + // Add-first-then-delete preserves the existing key if the save + // fails (e.g. keychain access denied), so the next bootstrap + // still has something to load. A plain delete-then-save would + // leave the vault permanently keyless on failure. + let oldKey: String + if identity.inboxId == "vault-pending" { + oldKey = "vault-pending" + Log.info("[Vault.bootstrap] persisting vault identity to keychain (was vault-pending, now inboxId=\(inboxId) clientId=\(installationId))") + } else { + oldKey = identity.inboxId + Log.info("[Vault.bootstrap] updating vault keychain entry: inboxId=\(inboxId) oldClientId=\(identity.clientId) newClientId=\(installationId)") + } + + do { + try await vaultKeyStore.save( + inboxId: inboxId, + clientId: installationId, + keys: identity.keys + ) + } catch { + Log.error("[Vault.bootstrap] failed to save updated vault keychain entry: \(error)") + throw error + } + + // Only delete the old entry after the new one is confirmed in + // the keychain. If the two entries share a key (same inboxId, + // only clientId changed), save() will overwrite in place and + // this delete is a no-op. + if oldKey != inboxId { + do { + try await vaultKeyStore.delete(inboxId: oldKey) + } catch { + // Non-fatal: the new entry is saved, so the vault is + // recoverable on next bootstrap. Just leaves an orphan. + Log.warning("[Vault.bootstrap] failed to delete old vault keychain entry (\(oldKey)) after save — leaving orphan: \(error)") + } + } + } else { + Log.info("[Vault.bootstrap] keychain identity already up-to-date (inboxId=\(inboxId))") } + Log.info("[Vault.bootstrap] saving vault inbox row to GRDB: inboxId=\(inboxId) clientId=\(installationId)") let inboxWriter = InboxWriter(dbWriter: databaseWriter) try await inboxWriter.save(inboxId: inboxId, clientId: installationId, isVault: true) bootstrapState = .ready - Log.info("Vault bootstrapped: inboxId=\(inboxId)") + Log.info("[Vault.bootstrap] bootstrapped successfully: inboxId=\(inboxId)") + + // Diagnostic: log whether this vault installation is still active on the network. + // If this returns false, it usually means another device restored from backup and + // revoked this device's vault installation. The conversation-level stale detection + // (InboxStateMachine) should handle the user-facing recovery — this log is just + // for diagnosing rare partial-revocation states during QA. + if let xmtpClient = await vaultClient.xmtpClient { + do { + let state = try await xmtpClient.inboxState(refreshFromNetwork: true) + let isActive = state.installations.contains { $0.id == installationId } + if isActive { + Log.info("[Vault.bootstrap] vault installation is active on network ✓") + } else { + Log.warning("[Vault.bootstrap] vault installation NOT in active list — vault is stale (likely revoked by another device)") + QAEvent.emit(.vault, "stale_detected", ["inboxId": inboxId]) + } + } catch { + Log.debug("[Vault.bootstrap] inboxState check failed (non-fatal): \(error)") + } + } + await keyCoordinator.startObservingInboxes() healthCheck = VaultHealthCheck( @@ -179,14 +240,18 @@ public actor VaultManager { private func loadOrCreateVaultIdentity(vaultKeyStore: VaultKeyStore) async throws -> KeychainIdentity { if let existing = try? await vaultKeyStore.loadAny() { + Log.info("[Vault.loadOrCreateVaultIdentity] found existing vault identity: inboxId=\(existing.inboxId)") return existing } + Log.info("[Vault.loadOrCreateVaultIdentity] no existing vault identity, generating fresh keys") let newKeys = try KeychainIdentityKeys.generate() - return try await vaultKeyStore.save( + let saved = try await vaultKeyStore.save( inboxId: "vault-pending", clientId: "vault-pending", keys: newKeys ) + Log.info("[Vault.loadOrCreateVaultIdentity] saved new vault-pending identity, awaiting inboxId from XMTP") + return saved } // MARK: - Lifecycle @@ -195,6 +260,183 @@ public actor VaultManager { await vaultClient.disconnect() } + /// Tears down the current vault and bootstraps a fresh one with new keys. + /// + /// Used after restore: the restored vault is inactive because the new + /// installation was never added to the old vault's MLS group, and the vault + /// is a single-user group with no third party to trigger re-addition. + /// This method: + /// 1. Disconnects the current vault client + /// 2. Deletes the old vault key from local + iCloud keychain + /// 3. Deletes the old DBInbox row for the vault + /// 4. Re-runs `bootstrapVault()` which generates fresh keys and creates + /// a new vault (new inboxId, new MLS group) + /// + /// The old vault XMTP database file on disk is left as-is — it's no longer + /// referenced by anything, but we don't touch it to avoid risk. A future + /// cleanup pass can remove orphaned vault DB files. + public func reCreate( + databaseWriter: any DatabaseWriter, + environment: AppEnvironment + ) async throws { + Log.info("[Vault.reCreate] === START ===") + + let oldInboxId = await vaultInboxId + let oldInstallationId = await vaultClient.installationId + let oldBootstrapState = stateDescription + Log.info("[Vault.reCreate] before: inboxId=\(oldInboxId ?? "nil") installationId=\(oldInstallationId ?? "nil") state=\(oldBootstrapState)") + + if let vaultKeyStore { + let beforeKeys = (try? await vaultKeyStore.loadAll()) ?? [] + Log.info("[Vault.reCreate] before: keychain has \(beforeKeys.count) vault key(s): \(beforeKeys.map(\.inboxId))") + } + + let beforeInboxCount: Int + do { + beforeInboxCount = try await databaseWriter.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM inbox WHERE isVault = 1") ?? 0 + } + Log.info("[Vault.reCreate] before: GRDB has \(beforeInboxCount) vault inbox row(s)") + } catch { + Log.warning("[Vault.reCreate] failed to count vault inbox rows: \(error)") + } + + Log.info("[Vault.reCreate] step 1/5: revoking all installations on old vault") + if let oldInboxId, let vaultKeyStore { + do { + let identity = try await vaultKeyStore.load(inboxId: oldInboxId) + // Use the stateless network-only revoker. The local vault client + // has been paused by `prepareForRestore` so its connection pool + // is disconnected; using `revokeAllOtherInstallations(signingKey:)` + // here would hit `Client error: Pool needs to reconnect before use`. + // We want to revoke every installation on the old inbox (including + // the current one, since reCreate is about abandoning the whole + // vault identity), so `keepInstallationId` is nil. + let count = try await XMTPInstallationRevoker.revokeOtherInstallations( + inboxId: oldInboxId, + signingKey: identity.keys.signingKey, + keepInstallationId: nil, + environment: environment + ) + Log.info("[Vault.reCreate] step 1/5: revoked \(count) installation(s) on old vault inboxId=\(oldInboxId)") + } catch { + Log.warning("[Vault.reCreate] step 1/5: revocation failed (non-fatal): \(error)") + } + } else { + Log.info("[Vault.reCreate] step 1/5: skipped — no old vault identity available") + } + + Log.info("[Vault.reCreate] step 2/5: disconnecting current vault client") + await vaultClient.disconnect() + bootstrapState = .notStarted + Log.info("[Vault.reCreate] step 2/5: disconnected, bootstrap state reset to notStarted") + + Log.info("[Vault.reCreate] step 3/5: deleting old vault key from keychain") + if let vaultKeyStore, let oldInboxId { + do { + try await vaultKeyStore.delete(inboxId: oldInboxId) + Log.info("[Vault.reCreate] step 3/5: deleted old vault key for inboxId=\(oldInboxId)") + } catch { + // Critical: if we can't delete the old key, the next bootstrap will pick it up + // and we'll re-create with the same inboxId — which means no new vault. + Log.error("[Vault.reCreate] step 3/5: failed to delete old vault key for \(oldInboxId): \(error)") + throw VaultReCreateError.bootstrapFailed("failed to delete old vault key: \(error.localizedDescription)") + } + + // Verify deletion and log any remaining keys + let remainingKeys = (try? await vaultKeyStore.loadAll()) ?? [] + Log.info("[Vault.reCreate] step 3/5: keychain now has \(remainingKeys.count) vault key(s) remaining: \(remainingKeys.map(\.inboxId))") + } else if let vaultKeyStore { + // No known inboxId (e.g., disconnect failed before we captured it) — delete all + Log.warning("[Vault.reCreate] step 3/5: no oldInboxId available, deleting all vault keys") + do { + try await vaultKeyStore.deleteAll() + } catch { + Log.error("[Vault.reCreate] step 3/5: failed to delete all vault keys: \(error)") + throw VaultReCreateError.bootstrapFailed("failed to delete vault keys: \(error.localizedDescription)") + } + let remainingKeys = (try? await vaultKeyStore.loadAll()) ?? [] + Log.info("[Vault.reCreate] step 3/5: keychain now has \(remainingKeys.count) vault key(s) remaining") + } else { + Log.warning("[Vault.reCreate] step 3/5: no vaultKeyStore available, skipping keychain cleanup") + } + + Log.info("[Vault.reCreate] step 4/5: deleting old vault inbox row from GRDB") + do { + try await databaseWriter.write { db in + if let oldInboxId { + try db.execute( + sql: "DELETE FROM inbox WHERE inboxId = ? AND isVault = 1", + arguments: [oldInboxId] + ) + } else { + // No known inboxId — match the keychain cleanup behaviour and + // remove all vault inbox rows so the next bootstrap doesn't + // collide with an orphan via InboxWriterError.duplicateVault. + try db.execute(sql: "DELETE FROM inbox WHERE isVault = 1") + } + } + Log.info("[Vault.reCreate] step 4/5: deleted old DBInbox row(s)") + } catch { + Log.error("[Vault.reCreate] step 4/5: failed to delete old DBInbox row(s): \(error)") + throw VaultReCreateError.bootstrapFailed("failed to delete old vault inbox row(s): \(error.localizedDescription)") + } + + let afterDeleteCount = (try? await databaseWriter.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM inbox WHERE isVault = 1") ?? 0 + }) ?? -1 + Log.info("[Vault.reCreate] step 4/5: GRDB now has \(afterDeleteCount) vault inbox row(s) remaining") + + Log.info("[Vault.reCreate] step 5/5: bootstrapping fresh vault (new keys, new identity)") + await bootstrapVault(databaseWriter: databaseWriter, environment: environment) + + let afterBootstrapState = stateDescription + Log.info("[Vault.reCreate] step 5/5: bootstrap finished with state=\(afterBootstrapState)") + + guard case .ready = bootstrapState else { + Log.error("[Vault.reCreate] === FAILED === bootstrap did not reach ready state: \(afterBootstrapState)") + throw VaultReCreateError.bootstrapFailed(afterBootstrapState) + } + + let newInboxId = await vaultInboxId ?? "unknown" + let newInstallationId = await vaultClient.installationId ?? "unknown" + Log.info("[Vault.reCreate] after: inboxId=\(newInboxId) installationId=\(newInstallationId)") + + if let oldInboxId, oldInboxId == newInboxId { + Log.error("[Vault.reCreate] new inboxId matches old inboxId — vault re-creation failed") + throw VaultReCreateError.bootstrapFailed("new inboxId (\(newInboxId)) matches old inboxId — re-creation did not produce a fresh vault") + } + if let oldInboxId { + Log.info("[Vault.reCreate] confirmed fresh vault: oldInboxId=\(oldInboxId) != newInboxId=\(newInboxId)") + } + + if let vaultKeyStore { + let afterKeys = (try? await vaultKeyStore.loadAll()) ?? [] + Log.info("[Vault.reCreate] after: keychain has \(afterKeys.count) vault key(s): \(afterKeys.map(\.inboxId))") + } + + Log.info("[Vault.reCreate] === DONE ===") + } + + private var stateDescription: String { + switch bootstrapState { + case .notStarted: return "notStarted" + case .ready: return "ready" + case .failed(let reason): return "failed(\(reason))" + } + } + + public var vaultInstallationId: String? { + get async { await vaultClient.installationId } + } + + public func revokeAllOtherInstallations(signingKey: any SigningKey) async throws { + guard let client = await vaultClient.xmtpClient else { + throw VaultClientError.notConnected + } + try await client.revokeAllOtherInstallations(signingKey: signingKey) + } + public func pause() async { await vaultClient.pause() } @@ -258,14 +500,18 @@ public actor VaultManager { ) try await vaultClient.send(removal, codec: DeviceRemovedCodec()) - await cleanupLocalVaultState(inboxId: selfInboxId) + await cleanupLocalVaultState(inboxId: selfInboxId, preserveICloudBackupKey: true) } - private func cleanupLocalVaultState(inboxId: String?) async { + private func cleanupLocalVaultState(inboxId: String?, preserveICloudBackupKey: Bool) async { await vaultClient.disconnect() if let inboxId { - try? await vaultKeyStore?.delete(inboxId: inboxId) + if preserveICloudBackupKey { + try? await vaultKeyStore?.deleteLocal(inboxId: inboxId) + } else { + try? await vaultKeyStore?.delete(inboxId: inboxId) + } } if let databaseWriter { @@ -280,7 +526,7 @@ public actor VaultManager { func handleSelfRemoved() async { Log.info("This device was removed from the vault by another device") let selfInboxId = await vaultInboxId - await cleanupLocalVaultState(inboxId: selfInboxId) + await cleanupLocalVaultState(inboxId: selfInboxId, preserveICloudBackupKey: false) } public static var preview: VaultManager { @@ -334,3 +580,14 @@ extension VaultManager: VaultClientDelegate { Log.error("VaultClient error: \(error)") } } + +public enum VaultReCreateError: Error, LocalizedError { + case bootstrapFailed(String) + + public var errorDescription: String? { + switch self { + case .bootstrapFailed(let reason): + return "Vault re-create failed during bootstrap: \(reason)" + } + } +} diff --git a/ConvosCore/Sources/ConvosCoreiOS/BackgroundUploadManager.swift b/ConvosCore/Sources/ConvosCoreiOS/BackgroundUploadManager.swift index 394613a65..8fd18e232 100644 --- a/ConvosCore/Sources/ConvosCoreiOS/BackgroundUploadManager.swift +++ b/ConvosCore/Sources/ConvosCoreiOS/BackgroundUploadManager.swift @@ -32,7 +32,6 @@ public final class BackgroundUploadManager: NSObject, BackgroundUploadManagerPro config.isDiscretionary = false config.sessionSendsLaunchEvents = true config.allowsCellularAccess = true - config.shouldUseExtendedBackgroundIdleMode = true backgroundSession = URLSession( configuration: config, diff --git a/ConvosCore/Tests/ConvosCoreTests/BackupBundleTests.swift b/ConvosCore/Tests/ConvosCoreTests/BackupBundleTests.swift new file mode 100644 index 000000000..058e876fe --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/BackupBundleTests.swift @@ -0,0 +1,561 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +// MARK: - Mock archive provider + +actor MockBackupArchiveProvider: BackupArchiveProvider { + var vaultArchiveCalls: [(URL, Data)] = [] + var conversationArchiveCalls: [(String, String, Data)] = [] + var failingInboxIds: Set = [] + + func setFailingInboxIds(_ ids: Set) { + failingInboxIds = ids + } + + func broadcastKeysToVault() async throws {} + + func createVaultArchive(at path: URL, encryptionKey: Data) async throws { + vaultArchiveCalls.append((path, encryptionKey)) + try Data("vault-archive-data".utf8).write(to: path) + } + + func createConversationArchive(inboxId: String, at path: String, encryptionKey: Data) async throws { + if failingInboxIds.contains(inboxId) { + throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "simulated failure"]) + } + conversationArchiveCalls.append((inboxId, path, encryptionKey)) + try Data("conversation-\(inboxId)".utf8).write(to: URL(fileURLWithPath: path)) + } +} + +// MARK: - Metadata Tests + +@Suite("BackupBundleMetadata Tests") +struct BackupBundleMetadataTests { + @Test("Metadata round-trips through JSON") + func testMetadataRoundTrip() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("metadata-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let metadata = BackupBundleMetadata( + version: 1, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + deviceId: "test-device-id", + deviceName: "Test iPhone", + osString: "ios", + inboxCount: 5 + ) + + try BackupBundleMetadata.write(metadata, to: tempDir) + let loaded = try BackupBundleMetadata.read(from: tempDir) + + #expect(loaded.version == 1) + #expect(loaded.deviceId == "test-device-id") + #expect(loaded.deviceName == "Test iPhone") + #expect(loaded.osString == "ios") + #expect(loaded.inboxCount == 5) + #expect(loaded.createdAt == Date(timeIntervalSince1970: 1_700_000_000)) + } + + @Test("Metadata defaults to version 1") + func testMetadataDefaultVersion() { + let metadata = BackupBundleMetadata( + deviceId: "id", + deviceName: "name", + osString: "ios", + inboxCount: 0 + ) + #expect(metadata.version == 1) + } +} + +// MARK: - Crypto Tests + +@Suite("BackupBundleCrypto Tests") +struct BackupBundleCryptoTests { + @Test("Encrypt and decrypt round-trips data") + func testEncryptDecryptRoundTrip() throws { + let key = Data(repeating: 0xAB, count: 32) + let plaintext = Data("Hello, backup world!".utf8) + + let encrypted = try BackupBundleCrypto.encrypt(data: plaintext, key: key) + #expect(encrypted != plaintext) + + let decrypted = try BackupBundleCrypto.decrypt(data: encrypted, key: key) + #expect(decrypted == plaintext) + } + + @Test("Decrypt with wrong key fails") + func testDecryptWrongKey() throws { + let key1 = Data(repeating: 0xAB, count: 32) + let key2 = Data(repeating: 0xCD, count: 32) + let plaintext = Data("secret".utf8) + + let encrypted = try BackupBundleCrypto.encrypt(data: plaintext, key: key1) + #expect(throws: BackupBundleCrypto.CryptoError.self) { + _ = try BackupBundleCrypto.decrypt(data: encrypted, key: key2) + } + } + + @Test("Invalid key length throws") + func testInvalidKeyLength() { + let shortKey = Data(repeating: 0xAB, count: 16) + let plaintext = Data("test".utf8) + + #expect(throws: BackupBundleCrypto.CryptoError.self) { + _ = try BackupBundleCrypto.encrypt(data: plaintext, key: shortKey) + } + #expect(throws: BackupBundleCrypto.CryptoError.self) { + _ = try BackupBundleCrypto.decrypt(data: plaintext, key: shortKey) + } + } + + @Test("Encrypts empty data") + func testEncryptEmptyData() throws { + let key = Data(repeating: 0xAB, count: 32) + let encrypted = try BackupBundleCrypto.encrypt(data: Data(), key: key) + let decrypted = try BackupBundleCrypto.decrypt(data: encrypted, key: key) + #expect(decrypted == Data()) + } + + @Test("Encrypts large data") + func testEncryptLargeData() throws { + let key = Data(repeating: 0xAB, count: 32) + let plaintext = Data(repeating: 0xFF, count: 1_000_000) + let encrypted = try BackupBundleCrypto.encrypt(data: plaintext, key: key) + let decrypted = try BackupBundleCrypto.decrypt(data: encrypted, key: key) + #expect(decrypted == plaintext) + } +} + +// MARK: - Bundle Tar Tests + +@Suite("BackupBundle Tar Tests") +struct BackupBundleTarTests { + @Test("Tar and untar round-trips directory contents") + func testTarRoundTrip() throws { + let sourceDir = FileManager.default.temporaryDirectory + .appendingPathComponent("tar-source-\(UUID().uuidString)") + let destDir = FileManager.default.temporaryDirectory + .appendingPathComponent("tar-dest-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: sourceDir) + try? FileManager.default.removeItem(at: destDir) + } + + try Data("file-a".utf8).write(to: sourceDir.appendingPathComponent("a.txt")) + let subDir = sourceDir.appendingPathComponent("sub", isDirectory: true) + try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) + try Data("file-b".utf8).write(to: subDir.appendingPathComponent("b.bin")) + + let tarData = try BackupBundle.tarDirectory(sourceDir) + try BackupBundle.untarData(tarData, to: destDir) + + let aData = try Data(contentsOf: destDir.appendingPathComponent("a.txt")) + #expect(String(data: aData, encoding: .utf8) == "file-a") + + let bData = try Data(contentsOf: destDir.appendingPathComponent("sub/b.bin")) + #expect(String(data: bData, encoding: .utf8) == "file-b") + } + + @Test("Pack and unpack round-trips with encryption") + func testPackUnpackRoundTrip() throws { + let sourceDir = FileManager.default.temporaryDirectory + .appendingPathComponent("pack-source-\(UUID().uuidString)") + let destDir = FileManager.default.temporaryDirectory + .appendingPathComponent("pack-dest-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: sourceDir) + try? FileManager.default.removeItem(at: destDir) + } + + try Data("hello".utf8).write(to: sourceDir.appendingPathComponent("test.txt")) + + let key = Data(repeating: 0x42, count: 32) + let packed = try BackupBundle.pack(directory: sourceDir, encryptionKey: key) + try BackupBundle.unpack(data: packed, encryptionKey: key, to: destDir) + + let recovered = try Data(contentsOf: destDir.appendingPathComponent("test.txt")) + #expect(String(data: recovered, encoding: .utf8) == "hello") + } + + @Test("Unpack with wrong key fails") + func testUnpackWrongKey() throws { + let sourceDir = FileManager.default.temporaryDirectory + .appendingPathComponent("pack-wrongkey-\(UUID().uuidString)") + let destDir = FileManager.default.temporaryDirectory + .appendingPathComponent("pack-wrongkey-dest-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: sourceDir) + try? FileManager.default.removeItem(at: destDir) + } + + try Data("secret".utf8).write(to: sourceDir.appendingPathComponent("s.txt")) + + let key1 = Data(repeating: 0x42, count: 32) + let key2 = Data(repeating: 0x99, count: 32) + let packed = try BackupBundle.pack(directory: sourceDir, encryptionKey: key1) + + #expect(throws: (any Error).self) { + try BackupBundle.unpack(data: packed, encryptionKey: key2, to: destDir) + } + } + + @Test("Untar rejects path traversal attempts") + func testUntarRejectsPathTraversal() throws { + var maliciousArchive = Data() + + let maliciousPath = Data("../../etc/evil.txt".utf8) + var pathLength = UInt32(maliciousPath.count).bigEndian + maliciousArchive.append(Data(bytes: &pathLength, count: 4)) + maliciousArchive.append(maliciousPath) + + let fileData = Data("malicious".utf8) + var fileLength = UInt64(fileData.count).bigEndian + maliciousArchive.append(Data(bytes: &fileLength, count: 8)) + maliciousArchive.append(fileData) + + let destDir = FileManager.default.temporaryDirectory + .appendingPathComponent("traversal-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: destDir) } + + #expect(throws: BackupBundle.BundleError.self) { + try BackupBundle.untarData(maliciousArchive, to: destDir) + } + } + + @Test("Empty directory tars to empty data") + func testEmptyDirectoryTar() throws { + let sourceDir = FileManager.default.temporaryDirectory + .appendingPathComponent("tar-empty-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: sourceDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: sourceDir) } + + let tarData = try BackupBundle.tarDirectory(sourceDir) + #expect(tarData.isEmpty) + } +} + +// MARK: - BackupManager Tests + +@Suite("BackupManager Tests", .serialized) +struct BackupManagerTests { + @Test("createBackup produces encrypted bundle with metadata") + func testCreateBackupProducesBundle() async throws { + let fixtures = TestFixtures() + let archiveProvider = MockBackupArchiveProvider() + let identityStore = MockKeychainIdentityStore() + let vaultKeyStore = try await seedVaultKey(store: identityStore) + + try await seedConversationIdentity(store: identityStore, inboxId: "inbox-1", clientId: "client-1") + try await seedConversationIdentity(store: identityStore, inboxId: "inbox-2", clientId: "client-2") + + let inboxWriter = InboxWriter(dbWriter: fixtures.databaseManager.dbWriter) + _ = try await inboxWriter.save(inboxId: "inbox-1", clientId: "client-1") + _ = try await inboxWriter.save(inboxId: "inbox-2", clientId: "client-2") + try await seedConversation( + databaseWriter: fixtures.databaseManager.dbWriter, + inboxId: "inbox-1", + clientId: "client-1", + isUnused: false + ) + try await seedConversation( + databaseWriter: fixtures.databaseManager.dbWriter, + inboxId: "inbox-2", + clientId: "client-2", + isUnused: false + ) + + let manager = BackupManager( + vaultKeyStore: vaultKeyStore, + archiveProvider: archiveProvider, + identityStore: identityStore, + databaseReader: fixtures.databaseManager.dbReader, + environment: .tests + ) + + let outputURL = try await manager.createBackup() + + #expect(FileManager.default.fileExists(atPath: outputURL.path)) + #expect(outputURL.lastPathComponent == "backup-latest.encrypted") + + let backupDir = outputURL.deletingLastPathComponent() + #expect(BackupBundleMetadata.exists(in: backupDir)) + let sidecarMetadata = try BackupBundleMetadata.read(from: backupDir) + #expect(sidecarMetadata.version == 1) + #expect(sidecarMetadata.inboxCount == 2) + + #expect(await archiveProvider.vaultArchiveCalls.count == 1) + #expect(await archiveProvider.conversationArchiveCalls.count == 2) + + try? FileManager.default.removeItem(at: backupDir) + try? await fixtures.cleanup() + } + + @Test("Single conversation failure does not fail whole backup") + func testPartialConversationFailure() async throws { + let fixtures = TestFixtures() + let archiveProvider = MockBackupArchiveProvider() + let identityStore = MockKeychainIdentityStore() + let vaultKeyStore = try await seedVaultKey(store: identityStore) + + try await seedConversationIdentity(store: identityStore, inboxId: "inbox-ok", clientId: "client-ok") + try await seedConversationIdentity(store: identityStore, inboxId: "inbox-fail", clientId: "client-fail") + + let inboxWriter = InboxWriter(dbWriter: fixtures.databaseManager.dbWriter) + _ = try await inboxWriter.save(inboxId: "inbox-ok", clientId: "client-ok") + _ = try await inboxWriter.save(inboxId: "inbox-fail", clientId: "client-fail") + try await seedConversation( + databaseWriter: fixtures.databaseManager.dbWriter, + inboxId: "inbox-ok", + clientId: "client-ok", + isUnused: false + ) + try await seedConversation( + databaseWriter: fixtures.databaseManager.dbWriter, + inboxId: "inbox-fail", + clientId: "client-fail", + isUnused: false + ) + + await archiveProvider.setFailingInboxIds(["inbox-fail"]) + + let manager = BackupManager( + vaultKeyStore: vaultKeyStore, + archiveProvider: archiveProvider, + identityStore: identityStore, + databaseReader: fixtures.databaseManager.dbReader, + environment: .tests + ) + + let outputURL = try await manager.createBackup() + #expect(FileManager.default.fileExists(atPath: outputURL.path)) + + #expect(await archiveProvider.vaultArchiveCalls.count == 1) + + try? FileManager.default.removeItem(at: outputURL.deletingLastPathComponent()) + try? await fixtures.cleanup() + } + + @Test("Bundle is decryptable with vault key") + func testBundleDecryptableWithVaultKey() async throws { + let fixtures = TestFixtures() + let archiveProvider = MockBackupArchiveProvider() + let identityStore = MockKeychainIdentityStore() + let vaultKeyStore = try await seedVaultKey(store: identityStore) + + let manager = BackupManager( + vaultKeyStore: vaultKeyStore, + archiveProvider: archiveProvider, + identityStore: identityStore, + databaseReader: fixtures.databaseManager.dbReader, + environment: .tests + ) + + let outputURL = try await manager.createBackup() + + let vaultIdentity = try await vaultKeyStore.loadAny() + let bundleData = try Data(contentsOf: outputURL) + + let unpackDir = FileManager.default.temporaryDirectory + .appendingPathComponent("unpack-test-\(UUID().uuidString)") + defer { + try? FileManager.default.removeItem(at: unpackDir) + try? FileManager.default.removeItem(at: outputURL.deletingLastPathComponent()) + } + + try BackupBundle.unpack(data: bundleData, encryptionKey: vaultIdentity.keys.databaseKey, to: unpackDir) + + let metadata = try BackupBundleMetadata.read(from: unpackDir) + #expect(metadata.version == 1) + + let vaultArchiveExists = FileManager.default.fileExists( + atPath: BackupBundle.vaultArchivePath(in: unpackDir).path + ) + #expect(vaultArchiveExists) + + let dbExists = FileManager.default.fileExists( + atPath: BackupBundle.databasePath(in: unpackDir).path + ) + #expect(dbExists) + + try? await fixtures.cleanup() + } + + @Test("Backup includes GRDB database copy") + func testBackupIncludesDatabase() async throws { + let fixtures = TestFixtures() + let archiveProvider = MockBackupArchiveProvider() + let identityStore = MockKeychainIdentityStore() + let vaultKeyStore = try await seedVaultKey(store: identityStore) + + try await seedConversationIdentity(store: identityStore, inboxId: "inbox-db-test", clientId: "client-db-test") + + let inboxWriter = InboxWriter(dbWriter: fixtures.databaseManager.dbWriter) + _ = try await inboxWriter.save(inboxId: "inbox-db-test", clientId: "client-db-test") + + let manager = BackupManager( + vaultKeyStore: vaultKeyStore, + archiveProvider: archiveProvider, + identityStore: identityStore, + databaseReader: fixtures.databaseManager.dbReader, + environment: .tests + ) + + let outputURL = try await manager.createBackup() + + let vaultIdentity = try await vaultKeyStore.loadAny() + let bundleData = try Data(contentsOf: outputURL) + let unpackDir = FileManager.default.temporaryDirectory + .appendingPathComponent("db-test-\(UUID().uuidString)") + defer { + try? FileManager.default.removeItem(at: unpackDir) + try? FileManager.default.removeItem(at: outputURL.deletingLastPathComponent()) + } + + try BackupBundle.unpack(data: bundleData, encryptionKey: vaultIdentity.keys.databaseKey, to: unpackDir) + + let restoredDbPath = BackupBundle.databasePath(in: unpackDir) + let restoredQueue = try DatabaseQueue(path: restoredDbPath.path) + let inboxCount = try await restoredQueue.read { db in + try DBInbox.fetchCount(db) + } + #expect(inboxCount == 1) + + try? await fixtures.cleanup() + } + + @Test("Backup excludes unused conversation inboxes from metadata and archives") + func testBackupExcludesUnusedConversationInboxes() async throws { + let fixtures = TestFixtures() + let archiveProvider = MockBackupArchiveProvider() + let identityStore = MockKeychainIdentityStore() + let vaultKeyStore = try await seedVaultKey(store: identityStore) + + try await seedConversationIdentity(store: identityStore, inboxId: "inbox-active", clientId: "client-active") + try await seedConversationIdentity(store: identityStore, inboxId: "inbox-unused", clientId: "client-unused") + + let inboxWriter = InboxWriter(dbWriter: fixtures.databaseManager.dbWriter) + _ = try await inboxWriter.save(inboxId: "inbox-active", clientId: "client-active") + _ = try await inboxWriter.save(inboxId: "inbox-unused", clientId: "client-unused") + + try await seedConversation( + databaseWriter: fixtures.databaseManager.dbWriter, + inboxId: "inbox-active", + clientId: "client-active", + isUnused: false + ) + try await seedConversation( + databaseWriter: fixtures.databaseManager.dbWriter, + inboxId: "inbox-unused", + clientId: "client-unused", + isUnused: true + ) + + let manager = BackupManager( + vaultKeyStore: vaultKeyStore, + archiveProvider: archiveProvider, + identityStore: identityStore, + databaseReader: fixtures.databaseManager.dbReader, + environment: .tests + ) + + let outputURL = try await manager.createBackup() + defer { + try? FileManager.default.removeItem(at: outputURL.deletingLastPathComponent()) + } + + let sidecarMetadata = try BackupBundleMetadata.read(from: outputURL.deletingLastPathComponent()) + #expect(sidecarMetadata.inboxCount == 1) + #expect(await archiveProvider.conversationArchiveCalls.count == 1) + #expect(await archiveProvider.conversationArchiveCalls.first?.0 == "inbox-active") + + try? await fixtures.cleanup() + } + + @Test("Backup with no conversation inboxes succeeds") + func testBackupWithNoConversations() async throws { + let fixtures = TestFixtures() + let archiveProvider = MockBackupArchiveProvider() + let identityStore = MockKeychainIdentityStore() + let vaultKeyStore = try await seedVaultKey(store: identityStore) + + let manager = BackupManager( + vaultKeyStore: vaultKeyStore, + archiveProvider: archiveProvider, + identityStore: identityStore, + databaseReader: fixtures.databaseManager.dbReader, + environment: .tests + ) + + let outputURL = try await manager.createBackup() + #expect(FileManager.default.fileExists(atPath: outputURL.path)) + + #expect(await archiveProvider.vaultArchiveCalls.count == 1) + #expect(await archiveProvider.conversationArchiveCalls.count == 0) + + try? FileManager.default.removeItem(at: outputURL.deletingLastPathComponent()) + try? await fixtures.cleanup() + } + + // MARK: - Helpers + + private func seedVaultKey(store: MockKeychainIdentityStore) async throws -> VaultKeyStore { + let keys = try await store.generateKeys() + _ = try await store.save(inboxId: "vault-inbox", clientId: "vault-client", keys: keys) + return VaultKeyStore(store: store) + } + + private func seedConversationIdentity( + store: MockKeychainIdentityStore, + inboxId: String, + clientId: String + ) async throws { + let keys = try await store.generateKeys() + _ = try await store.save(inboxId: inboxId, clientId: clientId, keys: keys) + } + + private func seedConversation( + databaseWriter: any DatabaseWriter, + inboxId: String, + clientId: String, + isUnused: Bool + ) async throws { + let conversation = DBConversation( + id: "conversation-\(inboxId)", + inboxId: inboxId, + clientId: clientId, + clientConversationId: "client-conversation-\(inboxId)", + inviteTag: "invite-\(inboxId)", + creatorId: inboxId, + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: false, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + imageLastRenewed: nil, + isUnused: isUnused + ) + + try await databaseWriter.write { db in + try conversation.save(db) + } + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ChronologicalSortIdTests.swift b/ConvosCore/Tests/ConvosCoreTests/ChronologicalSortIdTests.swift index 42e004a94..40bc420a1 100644 --- a/ConvosCore/Tests/ConvosCoreTests/ChronologicalSortIdTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/ChronologicalSortIdTests.swift @@ -427,7 +427,8 @@ struct ChronologicalSortIdTests { isUnread: false, isUnreadUpdatedAt: Date(), isMuted: false, - pinnedOrder: nil + pinnedOrder: nil, + isActive: true ).insert(db) try DBConversationMember( diff --git a/ConvosCore/Tests/ConvosCoreTests/ConversationLocalStateWriterTests.swift b/ConvosCore/Tests/ConvosCoreTests/ConversationLocalStateWriterTests.swift new file mode 100644 index 000000000..2d39d79c1 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ConversationLocalStateWriterTests.swift @@ -0,0 +1,150 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Tests for ConversationLocalStateWriter +/// +/// Covers the inactive conversation mode introduced for post-restore handling: +/// - setActive(_:for:) toggles the per-conversation flag +/// - markAllConversationsInactive() bulk-flips every row in one transaction +@Suite("ConversationLocalStateWriter Tests") +struct ConversationLocalStateWriterTests { + @Test("setActive flips a single conversation between active and inactive") + func testSetActiveTogglesSingleConversation() async throws { + let fixtures = TestFixtures() + let writer = ConversationLocalStateWriter(databaseWriter: fixtures.databaseManager.dbWriter) + + let conversationId = try await seedConversation(in: fixtures, id: "conv-1") + + try await writer.setActive(false, for: conversationId) + let inactive = try await fetchLocalState(in: fixtures, conversationId: conversationId) + #expect(inactive?.isActive == false) + + try await writer.setActive(true, for: conversationId) + let active = try await fetchLocalState(in: fixtures, conversationId: conversationId) + #expect(active?.isActive == true) + + try? await fixtures.cleanup() + } + + @Test("setActive throws when conversation does not exist") + func testSetActiveThrowsForUnknownConversation() async throws { + let fixtures = TestFixtures() + let writer = ConversationLocalStateWriter(databaseWriter: fixtures.databaseManager.dbWriter) + + await #expect(throws: ConversationLocalStateWriterError.self) { + try await writer.setActive(false, for: "missing") + } + + try? await fixtures.cleanup() + } + + @Test("markAllConversationsInactive flips every existing row in one pass") + func testMarkAllConversationsInactive() async throws { + let fixtures = TestFixtures() + let writer = ConversationLocalStateWriter(databaseWriter: fixtures.databaseManager.dbWriter) + + let ids = ["conv-a", "conv-b", "conv-c"] + for id in ids { + _ = try await seedConversation(in: fixtures, id: id) + } + + try await writer.markAllConversationsInactive() + + for id in ids { + let state = try await fetchLocalState(in: fixtures, conversationId: id) + #expect(state?.isActive == false, "expected \(id) to be inactive") + } + + try? await fixtures.cleanup() + } + + @Test("markAllConversationsInactive is a no-op when there are no rows") + func testMarkAllConversationsInactiveOnEmptyDatabase() async throws { + let fixtures = TestFixtures() + let writer = ConversationLocalStateWriter(databaseWriter: fixtures.databaseManager.dbWriter) + + try await writer.markAllConversationsInactive() + + let count = try await fixtures.databaseManager.dbReader.read { db in + try ConversationLocalState.fetchCount(db) + } + #expect(count == 0) + + try? await fixtures.cleanup() + } + + @Test("setActive on one conversation does not affect another") + func testSetActiveIsScopedToSingleConversation() async throws { + let fixtures = TestFixtures() + let writer = ConversationLocalStateWriter(databaseWriter: fixtures.databaseManager.dbWriter) + + let firstId = try await seedConversation(in: fixtures, id: "conv-x") + let secondId = try await seedConversation(in: fixtures, id: "conv-y") + + try await writer.setActive(false, for: firstId) + + let firstState = try await fetchLocalState(in: fixtures, conversationId: firstId) + let secondState = try await fetchLocalState(in: fixtures, conversationId: secondId) + #expect(firstState?.isActive == false) + #expect(secondState?.isActive == true) + + try? await fixtures.cleanup() + } + + // MARK: - Helpers + + private func seedConversation(in fixtures: TestFixtures, id: String) async throws -> String { + try await fixtures.databaseManager.dbWriter.write { db in + let conversation = DBConversation( + id: id, + inboxId: "inbox-\(id)", + clientId: "client-\(id)", + clientConversationId: id, + inviteTag: "tag-\(id)", + creatorId: "inbox-\(id)", + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: false, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + imageLastRenewed: nil, + isUnused: false + ) + try conversation.save(db) + + let localState = ConversationLocalState( + conversationId: id, + isPinned: false, + isUnread: false, + isUnreadUpdatedAt: Date.distantPast, + isMuted: false, + pinnedOrder: nil, + isActive: true + ) + try localState.save(db) + } + return id + } + + private func fetchLocalState( + in fixtures: TestFixtures, + conversationId: String + ) async throws -> ConversationLocalState? { + try await fixtures.databaseManager.dbReader.read { db in + try ConversationLocalState + .filter(ConversationLocalState.Columns.conversationId == conversationId) + .fetchOne(db) + } + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/DBMessageUpdateDecodingTests.swift b/ConvosCore/Tests/ConvosCoreTests/DBMessageUpdateDecodingTests.swift new file mode 100644 index 000000000..a140989d8 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/DBMessageUpdateDecodingTests.swift @@ -0,0 +1,82 @@ +@testable import ConvosCore +import Foundation +import Testing + +/// Tests for DBMessage.Update Codable conformance. +/// +/// Verifies the backward-compatible decoding of the `isReconnection` field +/// added for post-restore "Reconnected" message rendering. Existing messages +/// in the database were stored before the field existed and decoders must +/// default to `false` instead of throwing keyNotFound. +@Suite("DBMessage.Update decoding") +struct DBMessageUpdateDecodingTests { + @Test("Decode legacy JSON without isReconnection defaults to false") + func testLegacyDecodeDefaultsToFalse() throws { + let legacyJSON = """ + { + "initiatedByInboxId": "inbox-1", + "addedInboxIds": ["inbox-2"], + "removedInboxIds": [], + "metadataChanges": [] + } + """ + let data = try #require(legacyJSON.data(using: .utf8)) + let update = try JSONDecoder().decode(DBMessage.Update.self, from: data) + + #expect(update.initiatedByInboxId == "inbox-1") + #expect(update.addedInboxIds == ["inbox-2"]) + #expect(update.removedInboxIds.isEmpty) + #expect(update.metadataChanges.isEmpty) + #expect(update.expiresAt == nil) + #expect(update.isReconnection == false) + } + + @Test("Decode JSON with isReconnection=true preserves value") + func testDecodeWithReconnectionTrue() throws { + let json = """ + { + "initiatedByInboxId": "inbox-1", + "addedInboxIds": ["inbox-2"], + "removedInboxIds": [], + "metadataChanges": [], + "isReconnection": true + } + """ + let data = try #require(json.data(using: .utf8)) + let update = try JSONDecoder().decode(DBMessage.Update.self, from: data) + + #expect(update.isReconnection == true) + } + + @Test("Encode and round-trip preserves isReconnection") + func testEncodeRoundTrip() throws { + let original = DBMessage.Update( + initiatedByInboxId: "inbox-1", + addedInboxIds: ["inbox-2"], + removedInboxIds: [], + metadataChanges: [], + expiresAt: nil, + isReconnection: true + ) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DBMessage.Update.self, from: encoded) + + #expect(decoded.initiatedByInboxId == original.initiatedByInboxId) + #expect(decoded.addedInboxIds == original.addedInboxIds) + #expect(decoded.isReconnection == true) + } + + @Test("Memberwise init defaults isReconnection to false") + func testMemberwiseInitDefault() { + let update = DBMessage.Update( + initiatedByInboxId: "inbox-1", + addedInboxIds: [], + removedInboxIds: [], + metadataChanges: [], + expiresAt: nil + ) + + #expect(update.isReconnection == false) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/DefaultConversationDisplayTests.swift b/ConvosCore/Tests/ConvosCoreTests/DefaultConversationDisplayTests.swift index 607673cdb..53810b8f9 100644 --- a/ConvosCore/Tests/ConvosCoreTests/DefaultConversationDisplayTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/DefaultConversationDisplayTests.swift @@ -288,7 +288,8 @@ struct DefaultConversationDisplayTests { expiresAt: nil, debugInfo: .empty, isLocked: false, - assistantJoinStatus: nil + assistantJoinStatus: nil, + isActive: true ) #expect(conversation.avatarType == .customImage) } diff --git a/ConvosCore/Tests/ConvosCoreTests/InboxWriterTests.swift b/ConvosCore/Tests/ConvosCoreTests/InboxWriterTests.swift index e732ad0e2..1cb1c0578 100644 --- a/ConvosCore/Tests/ConvosCoreTests/InboxWriterTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/InboxWriterTests.swift @@ -140,4 +140,160 @@ struct InboxWriterTests { try? await fixtures.cleanup() } + + @Test("markStale flips an inbox's isStale flag") + func testMarkStaleFlipsFlag() async throws { + let fixtures = TestFixtures() + let inboxWriter = InboxWriter(dbWriter: fixtures.databaseManager.dbWriter) + + let inboxId = "stale-inbox" + _ = try await inboxWriter.save(inboxId: inboxId, clientId: ClientId.generate().value) + + let initial = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchOne(db, id: inboxId)?.isStale + } + #expect(initial == false) + + try await inboxWriter.markStale(inboxId: inboxId) + let afterMark = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchOne(db, id: inboxId)?.isStale + } + #expect(afterMark == true) + + try await inboxWriter.markStale(inboxId: inboxId, false) + let afterClear = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchOne(db, id: inboxId)?.isStale + } + #expect(afterClear == false) + + try? await fixtures.cleanup() + } + + @Test("markStale only affects the targeted inbox") + func testMarkStaleIsScopedToOneInbox() async throws { + let fixtures = TestFixtures() + let inboxWriter = InboxWriter(dbWriter: fixtures.databaseManager.dbWriter) + + _ = try await inboxWriter.save(inboxId: "inbox-a", clientId: ClientId.generate().value) + _ = try await inboxWriter.save(inboxId: "inbox-b", clientId: ClientId.generate().value) + + try await inboxWriter.markStale(inboxId: "inbox-a") + + let stale = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchOne(db, id: "inbox-a")?.isStale + } + let other = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchOne(db, id: "inbox-b")?.isStale + } + #expect(stale == true) + #expect(other == false) + + try? await fixtures.cleanup() + } + + @Test("deleteAll removes data from all tables") + func testDeleteAllRemovesAllData() async throws { + let fixtures = TestFixtures() + let db = fixtures.databaseManager.dbWriter + + let inboxWriter = InboxWriter(dbWriter: db) + _ = try await inboxWriter.save(inboxId: "inbox-1", clientId: "client-1") + + try await db.write { db in + let conversation = DBConversation( + id: "conv-1", + inboxId: "inbox-1", + clientId: "client-1", + clientConversationId: "conv-1", + inviteTag: "", + creatorId: "inbox-1", + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: false, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + imageLastRenewed: nil, + isUnused: false + ) + try conversation.save(db) + + let member = DBMember(inboxId: "inbox-1") + try member.save(db) + + let memberProfile = DBMemberProfile( + conversationId: "conv-1", + inboxId: "inbox-1", + name: "Test", + avatar: nil + ) + try memberProfile.save(db) + + let localState = ConversationLocalState( + conversationId: "conv-1", + isPinned: false, + isUnread: false, + isUnreadUpdatedAt: Date(), + isMuted: false, + pinnedOrder: nil, + isActive: true + ) + try localState.save(db) + + let conversationMember = DBConversationMember( + conversationId: "conv-1", + inboxId: "inbox-1", + role: .superAdmin, + consent: .allowed, + createdAt: Date(), + invitedByInboxId: nil + ) + try conversationMember.save(db) + + // invite references conversation_members via composite FK + // (creatorInboxId + conversationId). This exercises the FK ordering + // in deleteAll: invite must be deleted before conversation_members + // (or cascade must fire) to avoid constraint violations. + let invite = DBInvite( + creatorInboxId: "inbox-1", + conversationId: "conv-1", + urlSlug: "test-slug", + expiresAt: nil, + expiresAfterUse: false + ) + try invite.save(db) + } + + try await inboxWriter.deleteAll() + + let counts = try await fixtures.databaseManager.dbReader.read { db in + ( + inbox: try DBInbox.fetchCount(db), + conversation: try DBConversation.fetchCount(db), + member: try DBMember.fetchCount(db), + memberProfile: try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM memberProfile") ?? 0, + localState: try ConversationLocalState.fetchCount(db), + conversationMembers: try DBConversationMember.fetchCount(db), + invite: try DBInvite.fetchCount(db) + ) + } + + #expect(counts.inbox == 0) + #expect(counts.conversation == 0) + #expect(counts.member == 0) + #expect(counts.memberProfile == 0) + #expect(counts.localState == 0) + #expect(counts.conversationMembers == 0) + #expect(counts.invite == 0) + + try? await fixtures.cleanup() + } } diff --git a/ConvosCore/Tests/ConvosCoreTests/MessagesListProcessorBenchmarkTests.swift b/ConvosCore/Tests/ConvosCoreTests/MessagesListProcessorBenchmarkTests.swift index 7bb3e5fa8..49b0bfc07 100644 --- a/ConvosCore/Tests/ConvosCoreTests/MessagesListProcessorBenchmarkTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/MessagesListProcessorBenchmarkTests.swift @@ -50,7 +50,8 @@ private func seedMessages( creator: sender, addedMembers: [addedMember], removedMembers: [], - metadataChanges: [] + metadataChanges: [], + isReconnection: false )), date: date, reactions: [] diff --git a/ConvosCore/Tests/ConvosCoreTests/MessagesListProcessorTests.swift b/ConvosCore/Tests/ConvosCoreTests/MessagesListProcessorTests.swift index 057526bfe..a072d2ca2 100644 --- a/ConvosCore/Tests/ConvosCoreTests/MessagesListProcessorTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/MessagesListProcessorTests.swift @@ -87,7 +87,8 @@ private func makeUpdate( creator: otherUser, addedMembers: addedMembers.isEmpty ? [.mock(isCurrentUser: false, name: "NewMember")] : addedMembers, removedMembers: removedMembers, - metadataChanges: [] + metadataChanges: [], + isReconnection: false )), date: date, reactions: [] @@ -501,7 +502,8 @@ struct MessagesListProcessorUpdateTests { creator: otherUser, addedMembers: [], removedMembers: [], - metadataChanges: [.init(field: .metadata, oldValue: nil, newValue: "data")] + metadataChanges: [.init(field: .metadata, oldValue: nil, newValue: "data")], + isReconnection: false )), date: now, reactions: [] @@ -845,7 +847,8 @@ struct MessagesListProcessorAssistantJoinTests { creator: otherUser, addedMembers: [agentMember], removedMembers: [], - metadataChanges: [] + metadataChanges: [], + isReconnection: false )), date: now.addingTimeInterval(5), reactions: [] diff --git a/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryBenchmarkTests.swift b/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryBenchmarkTests.swift index 5c4d1087b..3de8528cd 100644 --- a/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryBenchmarkTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryBenchmarkTests.swift @@ -72,7 +72,8 @@ struct MessagesRepositoryBenchmarkTests { isUnread: false, isUnreadUpdatedAt: now, isMuted: false, - pinnedOrder: nil + pinnedOrder: nil, + isActive: true ).insert(db) for (i, inboxId) in memberInboxIds.enumerated() { diff --git a/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryTests.swift b/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryTests.swift index 778d92427..738cd18e9 100644 --- a/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryTests.swift @@ -430,7 +430,8 @@ struct MessagesRepositoryTests { isUnread: false, isUnreadUpdatedAt: now, isMuted: false, - pinnedOrder: nil + pinnedOrder: nil, + isActive: true ).insert(db) try DBConversationMember( diff --git a/ConvosCore/Tests/ConvosCoreTests/RestoreManagerTests.swift b/ConvosCore/Tests/ConvosCoreTests/RestoreManagerTests.swift new file mode 100644 index 000000000..3c7a27be4 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/RestoreManagerTests.swift @@ -0,0 +1,597 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing +import XMTPiOS + +// MARK: - Mock archive importer + +final class MockRestoreArchiveImporter: RestoreArchiveImporter, @unchecked Sendable { + var importedArchives: [(inboxId: String, path: String)] = [] + var failingInboxIds: Set = [] + + func importConversationArchive(inboxId: String, path: String, encryptionKey: Data) async throws -> String { + if failingInboxIds.contains(inboxId) { + throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "simulated import failure"]) + } + importedArchives.append((inboxId: inboxId, path: path)) + return "mock-installation-\(inboxId)" + } +} + +// MARK: - Mock vault service for restore + +final class MockVaultArchiveImporter: VaultArchiveImporter, @unchecked Sendable { + var keyEntriesToReturn: [VaultKeyEntry] = [] + + func importVaultArchive(from path: URL, encryptionKey: Data, vaultIdentity: KeychainIdentity) async throws -> [VaultKeyEntry] { + keyEntriesToReturn + } +} + +actor MockRestoreLifecycleController: RestoreLifecycleControlling { + private(set) var prepareCallCount: Int = 0 + private(set) var finishCallCount: Int = 0 + + func prepareForRestore() { + prepareCallCount += 1 + } + + func finishRestore() { + finishCallCount += 1 + } +} + +// MARK: - Tests + +@Suite("RestoreManager Tests", .serialized) +struct RestoreManagerTests { + @Test("Full restore flow decrypts bundle, replaces DB, reaches completed state") + func testFullRestoreFlow() async throws { + let fixtures = TestFixtures() + let identityStore = MockKeychainIdentityStore() + let (vaultKeyStore, vaultEncryptionKey) = try await seedVaultKey(store: identityStore) + let archiveImporter = MockRestoreArchiveImporter() + + let inboxWriter = InboxWriter(dbWriter: fixtures.databaseManager.dbWriter) + _ = try await inboxWriter.save(inboxId: "backup-inbox", clientId: "backup-client") + + let bundleURL = try await createTestBundle( + encryptionKey: vaultEncryptionKey, + identityStore: identityStore, + databaseManager: fixtures.databaseManager + ) + defer { try? FileManager.default.removeItem(at: bundleURL) } + + _ = try await inboxWriter.save(inboxId: "post-backup-inbox", clientId: "post-backup-client") + + let vaultImporter = MockVaultArchiveImporter() + let manager = RestoreManager( + vaultKeyStore: vaultKeyStore, + vaultArchiveImporter: vaultImporter, + identityStore: identityStore, + databaseManager: fixtures.databaseManager, + archiveImporter: archiveImporter, + environment: .tests + ) + + try await manager.restoreFromBackup(bundleURL: bundleURL) + + let finalState = await manager.state + guard case .completed = finalState else { + Issue.record("Expected completed state, got \(finalState)") + try? await fixtures.cleanup() + return + } + + let restoredInbox = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchOne(db, id: "backup-inbox") + } + #expect(restoredInbox != nil) + + let postBackupInbox = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchOne(db, id: "post-backup-inbox") + } + #expect(postBackupInbox == nil) + + try? await fixtures.cleanup() + } + + @Test("Restore replaces GRDB database with backup copy") + func testRestoreReplacesDatabase() async throws { + let fixtures = TestFixtures() + let identityStore = MockKeychainIdentityStore() + let (vaultKeyStore, vaultEncryptionKey) = try await seedVaultKey(store: identityStore) + let archiveImporter = MockRestoreArchiveImporter() + + let inboxWriter = InboxWriter(dbWriter: fixtures.databaseManager.dbWriter) + _ = try await inboxWriter.save(inboxId: "backup-inbox", clientId: "backup-client") + + let bundleURL = try await createTestBundle( + encryptionKey: vaultEncryptionKey, + identityStore: identityStore, + databaseManager: fixtures.databaseManager + ) + defer { try? FileManager.default.removeItem(at: bundleURL) } + + _ = try await inboxWriter.save(inboxId: "post-backup-inbox", clientId: "post-backup-client") + let preRestoreCount = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchCount(db) + } + #expect(preRestoreCount == 2) + + let vaultImporter = MockVaultArchiveImporter() + let manager = RestoreManager( + vaultKeyStore: vaultKeyStore, + vaultArchiveImporter: vaultImporter, + identityStore: identityStore, + databaseManager: fixtures.databaseManager, + archiveImporter: archiveImporter, + environment: .tests + ) + + try await manager.restoreFromBackup(bundleURL: bundleURL) + + let postRestoreCount = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchCount(db) + } + #expect(postRestoreCount == 1) + + let restoredInbox = try await fixtures.databaseManager.dbReader.read { db in + try DBInbox.fetchOne(db, id: "backup-inbox") + } + #expect(restoredInbox != nil) + + try? await fixtures.cleanup() + } + + @Test("Conversation archive import failure is non-fatal") + func testPartialConversationImportFailure() async throws { + let fixtures = TestFixtures() + let identityStore = MockKeychainIdentityStore() + let vaultStore = MockKeychainIdentityStore() + let (vaultKeyStore, vaultEncryptionKey) = try await seedVaultKey(store: vaultStore) + let archiveImporter = MockRestoreArchiveImporter() + + archiveImporter.failingInboxIds = ["conv-fail"] + + let bundleURL = try await createTestBundleWithConversations( + encryptionKey: vaultEncryptionKey, + identityStore: identityStore, + databaseManager: fixtures.databaseManager, + conversationInboxIds: ["conv-ok", "conv-fail"] + ) + defer { try? FileManager.default.removeItem(at: bundleURL) } + + let vaultImporter = MockVaultArchiveImporter() + let convOkKeys = try await identityStore.generateKeys() + let convFailKeys = try await identityStore.generateKeys() + vaultImporter.keyEntriesToReturn = [ + .init(inboxId: "conv-ok", clientId: "client-conv-ok", conversationId: "group-ok", + privateKeyData: convOkKeys.privateKey.secp256K1.bytes, + databaseKey: convOkKeys.databaseKey), + .init(inboxId: "conv-fail", clientId: "client-conv-fail", conversationId: "group-fail", + privateKeyData: convFailKeys.privateKey.secp256K1.bytes, + databaseKey: convFailKeys.databaseKey), + ] + + let manager = RestoreManager( + vaultKeyStore: vaultKeyStore, + vaultArchiveImporter: vaultImporter, + identityStore: identityStore, + databaseManager: fixtures.databaseManager, + archiveImporter: archiveImporter, + environment: .tests + ) + + try await manager.restoreFromBackup(bundleURL: bundleURL) + + let finalState = await manager.state + guard case .completed = finalState else { + Issue.record("Expected completed state, got \(finalState)") + try? await fixtures.cleanup() + return + } + + #expect(archiveImporter.importedArchives.count == 1) + #expect(archiveImporter.importedArchives.first?.inboxId == "conv-ok") + + try? await fixtures.cleanup() + } + + @Test("Restore aborts when vault archive is missing from the bundle") + func testMissingVaultArchiveAborts() async throws { + let fixtures = TestFixtures() + let identityStore = MockKeychainIdentityStore() + let (vaultKeyStore, vaultEncryptionKey) = try await seedVaultKey(store: identityStore) + let archiveImporter = MockRestoreArchiveImporter() + + let bundleURL = try await createBundleWithoutVaultArchive( + encryptionKey: vaultEncryptionKey, + databaseManager: fixtures.databaseManager + ) + defer { try? FileManager.default.removeItem(at: bundleURL) } + + let vaultImporter = MockVaultArchiveImporter() + let manager = RestoreManager( + vaultKeyStore: vaultKeyStore, + vaultArchiveImporter: vaultImporter, + identityStore: identityStore, + databaseManager: fixtures.databaseManager, + archiveImporter: archiveImporter, + environment: .tests + ) + + // Without a vault archive, there are no conversation keys to restore. + // Continuing would wipe local state and replace the database with no + // way to decrypt the resulting conversations — silent data loss. + // Verify the restore aborts before any destructive operation runs. + await #expect(throws: Error.self) { + try await manager.restoreFromBackup(bundleURL: bundleURL) + } + + let finalState = await manager.state + guard case .failed = finalState else { + Issue.record("Expected failed state, got \(finalState)") + try? await fixtures.cleanup() + return + } + + // Verify no destructive operation ran: the conversation archive importer + // should not have been invoked since we bail before reaching that step. + #expect(archiveImporter.importedArchives.isEmpty) + + try? await fixtures.cleanup() + } + + @Test("Restore detection finds available backup") + func testFindAvailableBackup() throws { + let backupsDir = FileManager.default.temporaryDirectory + .appendingPathComponent("backups", isDirectory: true) + .appendingPathComponent("test-device", isDirectory: true) + try FileManager.default.createDirectory(at: backupsDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: backupsDir.deletingLastPathComponent()) } + + let metadata = BackupBundleMetadata( + deviceId: "test-device", + deviceName: "Test iPhone", + osString: "ios", + inboxCount: 3 + ) + try BackupBundleMetadata.write(metadata, to: backupsDir) + try Data("encrypted-bundle".utf8).write(to: backupsDir.appendingPathComponent("backup-latest.encrypted")) + + let result = RestoreManager.findNewestBackup(in: backupsDir.deletingLastPathComponent()) + #expect(result != nil) + #expect(result?.metadata.deviceName == "Test iPhone") + #expect(result?.metadata.inboxCount == 3) + } + + @Test("Restore detection returns nil when no backup exists") + func testFindAvailableBackupReturnsNil() { + let emptyDir = FileManager.default.temporaryDirectory + .appendingPathComponent("no-backups-\(UUID().uuidString)") + let result = RestoreManager.findNewestBackup(in: emptyDir) + #expect(result == nil) + } + + @Test("RestoreState progresses through expected phases") + func testStateProgression() async throws { + let fixtures = TestFixtures() + let identityStore = MockKeychainIdentityStore() + let (vaultKeyStore, vaultEncryptionKey) = try await seedVaultKey(store: identityStore) + let archiveImporter = MockRestoreArchiveImporter() + + let bundleURL = try await createTestBundle( + encryptionKey: vaultEncryptionKey, + identityStore: identityStore, + databaseManager: fixtures.databaseManager + ) + defer { try? FileManager.default.removeItem(at: bundleURL) } + + let vaultImporter = MockVaultArchiveImporter() + let manager = RestoreManager( + vaultKeyStore: vaultKeyStore, + vaultArchiveImporter: vaultImporter, + identityStore: identityStore, + databaseManager: fixtures.databaseManager, + archiveImporter: archiveImporter, + environment: .tests + ) + + let initialState = await manager.state + #expect(initialState == .idle) + + try await manager.restoreFromBackup(bundleURL: bundleURL) + + let finalState = await manager.state + guard case .completed = finalState else { + Issue.record("Expected completed state, got \(finalState)") + try? await fixtures.cleanup() + return + } + + try? await fixtures.cleanup() + } + + @Test("Restore completed count excludes unused conversation inboxes") + func testRestoreCompletedCountExcludesUnusedConversationInboxes() async throws { + let fixtures = TestFixtures() + let identityStore = MockKeychainIdentityStore() + let (vaultKeyStore, vaultEncryptionKey) = try await seedVaultKey(store: identityStore) + let archiveImporter = MockRestoreArchiveImporter() + + let inboxWriter = InboxWriter(dbWriter: fixtures.databaseManager.dbWriter) + _ = try await inboxWriter.save(inboxId: "used-inbox", clientId: "used-client") + _ = try await inboxWriter.save(inboxId: "unused-inbox", clientId: "unused-client") + try await seedConversation( + databaseWriter: fixtures.databaseManager.dbWriter, + inboxId: "used-inbox", + clientId: "used-client", + isUnused: false + ) + try await seedConversation( + databaseWriter: fixtures.databaseManager.dbWriter, + inboxId: "unused-inbox", + clientId: "unused-client", + isUnused: true + ) + + let bundleURL = try await createTestBundle( + encryptionKey: vaultEncryptionKey, + identityStore: identityStore, + databaseManager: fixtures.databaseManager + ) + defer { try? FileManager.default.removeItem(at: bundleURL) } + + let vaultImporter = MockVaultArchiveImporter() + let manager = RestoreManager( + vaultKeyStore: vaultKeyStore, + vaultArchiveImporter: vaultImporter, + identityStore: identityStore, + databaseManager: fixtures.databaseManager, + archiveImporter: archiveImporter, + environment: .tests + ) + + try await manager.restoreFromBackup(bundleURL: bundleURL) + + let finalState = await manager.state + guard case .completed(let inboxCount, _) = finalState else { + Issue.record("Expected completed state, got \(finalState)") + try? await fixtures.cleanup() + return + } + #expect(inboxCount == 1) + + try? await fixtures.cleanup() + } + + @Test("Restore prepares and finishes app lifecycle around database replacement") + func testRestoreLifecycleControllerIsCalled() async throws { + let fixtures = TestFixtures() + let identityStore = MockKeychainIdentityStore() + let (vaultKeyStore, vaultEncryptionKey) = try await seedVaultKey(store: identityStore) + let archiveImporter = MockRestoreArchiveImporter() + let lifecycleController = MockRestoreLifecycleController() + + let bundleURL = try await createTestBundle( + encryptionKey: vaultEncryptionKey, + identityStore: identityStore, + databaseManager: fixtures.databaseManager + ) + defer { try? FileManager.default.removeItem(at: bundleURL) } + + let vaultImporter = MockVaultArchiveImporter() + let manager = RestoreManager( + vaultKeyStore: vaultKeyStore, + vaultArchiveImporter: vaultImporter, + identityStore: identityStore, + databaseManager: fixtures.databaseManager, + archiveImporter: archiveImporter, + restoreLifecycleController: lifecycleController, + environment: .tests + ) + + try await manager.restoreFromBackup(bundleURL: bundleURL) + + #expect(await lifecycleController.prepareCallCount == 1) + #expect(await lifecycleController.finishCallCount == 1) + + try? await fixtures.cleanup() + } + + // MARK: - Helpers + + private func seedVaultKey(store: MockKeychainIdentityStore) async throws -> (VaultKeyStore, Data) { + let keys = try await store.generateKeys() + _ = try await store.save(inboxId: "vault-inbox", clientId: "vault-client", keys: keys) + return (VaultKeyStore(store: store), keys.databaseKey) + } + + private func createTestBundle( + encryptionKey: Data, + identityStore: MockKeychainIdentityStore, + databaseManager: MockDatabaseManager + ) async throws -> URL { + + let stagingDir = try BackupBundle.createStagingDirectory() + defer { BackupBundle.cleanup(directory: stagingDir) } + + try Data("vault-archive-data".utf8).write(to: BackupBundle.vaultArchivePath(in: stagingDir)) + + let destPath = BackupBundle.databasePath(in: stagingDir) + let destQueue = try DatabaseQueue(path: destPath.path) + try databaseManager.dbReader.backup(to: destQueue) + + let metadata = BackupBundleMetadata( + deviceId: "test-device", + deviceName: "Test Device", + osString: "ios", + inboxCount: 0 + ) + try BackupBundleMetadata.write(metadata, to: stagingDir) + + let bundleData = try BackupBundle.pack(directory: stagingDir, encryptionKey: encryptionKey) + let bundleURL = FileManager.default.temporaryDirectory + .appendingPathComponent("test-bundle-\(UUID().uuidString).encrypted") + try bundleData.write(to: bundleURL) + return bundleURL + } + + private func createTestBundleWithConversations( + encryptionKey: Data, + identityStore: MockKeychainIdentityStore, + databaseManager: MockDatabaseManager, + conversationInboxIds: [String] + ) async throws -> URL { + + let stagingDir = try BackupBundle.createStagingDirectory() + defer { BackupBundle.cleanup(directory: stagingDir) } + + try Data("vault-archive-data".utf8).write(to: BackupBundle.vaultArchivePath(in: stagingDir)) + + for inboxId in conversationInboxIds { + let keys = try await identityStore.generateKeys() + _ = try await identityStore.save(inboxId: inboxId, clientId: "client-\(inboxId)", keys: keys) + + let archivePath = BackupBundle.conversationArchivePath(inboxId: inboxId, in: stagingDir) + try Data("conversation-\(inboxId)".utf8).write(to: archivePath) + } + + let destPath = BackupBundle.databasePath(in: stagingDir) + let destQueue = try DatabaseQueue(path: destPath.path) + try databaseManager.dbReader.backup(to: destQueue) + + let metadata = BackupBundleMetadata( + deviceId: "test-device", + deviceName: "Test Device", + osString: "ios", + inboxCount: conversationInboxIds.count + ) + try BackupBundleMetadata.write(metadata, to: stagingDir) + + let bundleData = try BackupBundle.pack(directory: stagingDir, encryptionKey: encryptionKey) + let bundleURL = FileManager.default.temporaryDirectory + .appendingPathComponent("test-bundle-\(UUID().uuidString).encrypted") + try bundleData.write(to: bundleURL) + return bundleURL + } + + private func createBundleWithoutVaultArchive( + encryptionKey: Data, + databaseManager: MockDatabaseManager + ) async throws -> URL { + + let stagingDir = try BackupBundle.createStagingDirectory() + defer { BackupBundle.cleanup(directory: stagingDir) } + + let destPath = BackupBundle.databasePath(in: stagingDir) + let destQueue = try DatabaseQueue(path: destPath.path) + try databaseManager.dbReader.backup(to: destQueue) + + let metadata = BackupBundleMetadata( + deviceId: "test-device", + deviceName: "Test Device", + osString: "ios", + inboxCount: 0 + ) + try BackupBundleMetadata.write(metadata, to: stagingDir) + + let bundleData = try BackupBundle.pack(directory: stagingDir, encryptionKey: encryptionKey) + let bundleURL = FileManager.default.temporaryDirectory + .appendingPathComponent("test-bundle-\(UUID().uuidString).encrypted") + try bundleData.write(to: bundleURL) + return bundleURL + } + + private func seedConversation( + databaseWriter: any DatabaseWriter, + inboxId: String, + clientId: String, + isUnused: Bool + ) async throws { + let conversation = DBConversation( + id: "conversation-\(inboxId)", + inboxId: inboxId, + clientId: clientId, + clientConversationId: "client-conversation-\(inboxId)", + inviteTag: "invite-\(inboxId)", + creatorId: inboxId, + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: false, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + imageLastRenewed: nil, + isUnused: isUnused + ) + + try await databaseWriter.write { db in + try conversation.save(db) + } + } +} + +@Suite("DatabaseManager Restore Tests", .serialized) +struct DatabaseManagerRestoreTests { + @Test("replaceDatabase keeps captured readers valid after restore") + func replaceDatabaseKeepsCapturedReadersValid() async throws { + let environment: AppEnvironment = .tests + let dbURL = environment.defaultDatabasesDirectoryURL.appendingPathComponent("convos.sqlite") + let walURL = URL(fileURLWithPath: dbURL.path + "-wal") + let shmURL = URL(fileURLWithPath: dbURL.path + "-shm") + let backupPath = FileManager.default.temporaryDirectory + .appendingPathComponent("database-restore-\(UUID().uuidString).sqlite") + + try? FileManager.default.removeItem(at: dbURL) + try? FileManager.default.removeItem(at: walURL) + try? FileManager.default.removeItem(at: shmURL) + try? FileManager.default.removeItem(at: backupPath) + + let manager = DatabaseManager(environment: environment) + defer { + try? manager.dbPool.close() + try? FileManager.default.removeItem(at: dbURL) + try? FileManager.default.removeItem(at: walURL) + try? FileManager.default.removeItem(at: shmURL) + try? FileManager.default.removeItem(at: backupPath) + } + + let inboxWriter = InboxWriter(dbWriter: manager.dbWriter) + _ = try await inboxWriter.save(inboxId: "backup-inbox", clientId: "backup-client") + + let backupQueue = try DatabaseQueue(path: backupPath.path) + defer { try? backupQueue.close() } + try manager.dbReader.backup(to: backupQueue) + + _ = try await inboxWriter.save(inboxId: "post-backup-inbox", clientId: "post-backup-client") + + let capturedReader = manager.dbReader + let preRestoreCount = try await capturedReader.read { db in + try DBInbox.fetchCount(db) + } + #expect(preRestoreCount == 2) + + try manager.replaceDatabase(with: backupPath) + + let restoredCount = try await capturedReader.read { db in + try DBInbox.fetchCount(db) + } + #expect(restoredCount == 1) + + let restoredInbox = try await capturedReader.read { db in + try DBInbox.fetchOne(db, id: "backup-inbox") + } + #expect(restoredInbox != nil) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/StaleDeviceStateTests.swift b/ConvosCore/Tests/ConvosCoreTests/StaleDeviceStateTests.swift new file mode 100644 index 000000000..328f4553e --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/StaleDeviceStateTests.swift @@ -0,0 +1,185 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Tests for `InboxesRepository.staleDeviceStatePublisher` and `StaleDeviceState` derivation. +/// +/// Verifies the partial-vs-full state model used to drive post-restore UX: +/// - healthy: no stale used inboxes +/// - partialStale: some but not all used inboxes are stale +/// - fullStale: every used inbox is stale +@Suite("StaleDeviceState derivation") +struct StaleDeviceStateTests { + @Test("healthy when no inboxes exist") + func testHealthyWhenNoInboxes() async throws { + let fixtures = TestFixtures() + let state = try await derivedState(in: fixtures) + #expect(state == .healthy) + try? await fixtures.cleanup() + } + + @Test("healthy when there are inboxes but no conversations") + func testHealthyWhenInboxHasNoConversations() async throws { + let fixtures = TestFixtures() + try await seedInbox(in: fixtures, id: "inbox-1", isVault: false, isStale: false) + + let state = try await derivedState(in: fixtures) + // Inbox has no non-unused conversations → not "used" → healthy + #expect(state == .healthy) + try? await fixtures.cleanup() + } + + @Test("healthy when used inbox is not stale") + func testHealthyWhenUsedInboxIsNotStale() async throws { + let fixtures = TestFixtures() + try await seedInbox(in: fixtures, id: "inbox-1", isVault: false, isStale: false) + try await seedConversation(in: fixtures, id: "conv-1", inboxId: "inbox-1") + + let state = try await derivedState(in: fixtures) + #expect(state == .healthy) + try? await fixtures.cleanup() + } + + @Test("partialStale when one of two used inboxes is stale") + func testPartialStaleWhenSomeUsedInboxesStale() async throws { + let fixtures = TestFixtures() + try await seedInbox(in: fixtures, id: "inbox-stale", isVault: false, isStale: true) + try await seedInbox(in: fixtures, id: "inbox-healthy", isVault: false, isStale: false) + try await seedConversation(in: fixtures, id: "conv-stale", inboxId: "inbox-stale") + try await seedConversation(in: fixtures, id: "conv-healthy", inboxId: "inbox-healthy") + + let state = try await derivedState(in: fixtures) + #expect(state == .partialStale) + try? await fixtures.cleanup() + } + + @Test("fullStale when every used inbox is stale") + func testFullStaleWhenAllUsedInboxesStale() async throws { + let fixtures = TestFixtures() + try await seedInbox(in: fixtures, id: "inbox-1", isVault: false, isStale: true) + try await seedInbox(in: fixtures, id: "inbox-2", isVault: false, isStale: true) + try await seedConversation(in: fixtures, id: "conv-1", inboxId: "inbox-1") + try await seedConversation(in: fixtures, id: "conv-2", inboxId: "inbox-2") + + let state = try await derivedState(in: fixtures) + #expect(state == .fullStale) + try? await fixtures.cleanup() + } + + @Test("vault inboxes are not counted toward state") + func testVaultInboxIsIgnored() async throws { + let fixtures = TestFixtures() + try await seedInbox(in: fixtures, id: "vault-inbox", isVault: true, isStale: false) + try await seedInbox(in: fixtures, id: "user-inbox", isVault: false, isStale: true) + try await seedConversation(in: fixtures, id: "conv-1", inboxId: "user-inbox") + + let state = try await derivedState(in: fixtures) + // Only the user inbox counts; it's the only used inbox and it's stale → fullStale + #expect(state == .fullStale) + try? await fixtures.cleanup() + } + + @Test("unused conversations do not count toward used-inbox check") + func testUnusedConversationsAreIgnored() async throws { + let fixtures = TestFixtures() + try await seedInbox(in: fixtures, id: "inbox-1", isVault: false, isStale: true) + try await seedConversation(in: fixtures, id: "conv-1", inboxId: "inbox-1", isUnused: true) + + let state = try await derivedState(in: fixtures) + // Only conversation is unused → inbox is not "used" → healthy + #expect(state == .healthy) + try? await fixtures.cleanup() + } + + @Test("StaleDeviceState convenience flags") + func testConvenienceFlags() { + #expect(StaleDeviceState.healthy.hasUsableInboxes == true) + #expect(StaleDeviceState.healthy.hasAnyStaleInboxes == false) + #expect(StaleDeviceState.partialStale.hasUsableInboxes == true) + #expect(StaleDeviceState.partialStale.hasAnyStaleInboxes == true) + #expect(StaleDeviceState.fullStale.hasUsableInboxes == false) + #expect(StaleDeviceState.fullStale.hasAnyStaleInboxes == true) + } + + // MARK: - Helpers + + private func derivedState(in fixtures: TestFixtures) async throws -> StaleDeviceState { + try await fixtures.databaseManager.dbReader.read { db in + let usedSql = """ + SELECT i.inboxId, i.isStale + FROM inbox i + WHERE i.isVault = 0 + AND EXISTS ( + SELECT 1 + FROM conversation c + WHERE c.inboxId = i.inboxId + AND c.isUnused = 0 + ) + """ + let rows = try Row.fetchAll(db, sql: usedSql) + let total = rows.count + let stale = rows.filter { $0["isStale"] as Bool == true }.count + + if total == 0 || stale == 0 { + return StaleDeviceState.healthy + } + if stale == total { + return StaleDeviceState.fullStale + } + return StaleDeviceState.partialStale + } + } + + private func seedInbox( + in fixtures: TestFixtures, + id: String, + isVault: Bool, + isStale: Bool + ) async throws { + try await fixtures.databaseManager.dbWriter.write { db in + let inbox = DBInbox( + inboxId: id, + clientId: "client-\(id)", + isVault: isVault, + isStale: isStale + ) + try inbox.save(db) + } + } + + private func seedConversation( + in fixtures: TestFixtures, + id: String, + inboxId: String, + isUnused: Bool = false + ) async throws { + try await fixtures.databaseManager.dbWriter.write { db in + let conversation = DBConversation( + id: id, + inboxId: inboxId, + clientId: "client-\(inboxId)", + clientConversationId: id, + inviteTag: "tag-\(id)", + creatorId: inboxId, + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: false, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + imageLastRenewed: nil, + isUnused: isUnused + ) + try conversation.save(db) + } + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/SyncingManagerTests.swift b/ConvosCore/Tests/ConvosCoreTests/SyncingManagerTests.swift index f0a192062..a082c0458 100644 --- a/ConvosCore/Tests/ConvosCoreTests/SyncingManagerTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/SyncingManagerTests.swift @@ -82,6 +82,13 @@ class TestableMockClient: XMTPClientProvider, @unchecked Sendable { func revokeInstallations(signingKey: any SigningKey, installationIds: [String]) async throws { } + func revokeAllOtherInstallations(signingKey: any SigningKey) async throws { + } + + func isInstallationActive() async throws -> Bool { + true + } + func requestDeviceSync() async throws { } diff --git a/ConvosCore/Tests/ConvosCoreTests/VaultKeyStoreTests.swift b/ConvosCore/Tests/ConvosCoreTests/VaultKeyStoreTests.swift index c22f25f59..910ca659c 100644 --- a/ConvosCore/Tests/ConvosCoreTests/VaultKeyStoreTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/VaultKeyStoreTests.swift @@ -84,6 +84,49 @@ struct VaultKeyStoreTests { #expect(await store.exists() == false) } + @Test("Delete local preserves iCloud vault key copy") + func deleteLocalPreservesICloudCopy() async throws { + let localStore = MockKeychainIdentityStore() + let iCloudStore = MockKeychainIdentityStore() + let dualStore = ICloudIdentityStore(localStore: localStore, icloudStore: iCloudStore) + let store = VaultKeyStore(store: dualStore) + + let keys = try KeychainIdentityKeys.generate() + try await store.save(inboxId: vaultInboxId, clientId: vaultClientId, keys: keys) + + try await store.deleteLocal(inboxId: vaultInboxId) + + await #expect(throws: KeychainIdentityStoreError.self) { + _ = try await localStore.identity(for: self.vaultInboxId) + } + + let iCloudIdentity = try await iCloudStore.identity(for: vaultInboxId) + #expect(iCloudIdentity.inboxId == vaultInboxId) + + let reloaded = try await store.loadAny() + #expect(reloaded.keys.databaseKey == keys.databaseKey) + } + + @Test("Delete removes both local and iCloud vault key copies") + func deleteRemovesBothCopies() async throws { + let localStore = MockKeychainIdentityStore() + let iCloudStore = MockKeychainIdentityStore() + let dualStore = ICloudIdentityStore(localStore: localStore, icloudStore: iCloudStore) + let store = VaultKeyStore(store: dualStore) + + let keys = try KeychainIdentityKeys.generate() + try await store.save(inboxId: vaultInboxId, clientId: vaultClientId, keys: keys) + + try await store.delete(inboxId: vaultInboxId) + + await #expect(throws: KeychainIdentityStoreError.self) { + _ = try await localStore.identity(for: self.vaultInboxId) + } + await #expect(throws: KeychainIdentityStoreError.self) { + _ = try await iCloudStore.identity(for: self.vaultInboxId) + } + } + @Test("DeleteAll clears everything") func deleteAll() async throws { let mock = MockKeychainIdentityStore() diff --git a/docs/plans/backup-restore-followups.md b/docs/plans/backup-restore-followups.md new file mode 100644 index 000000000..146ff25af --- /dev/null +++ b/docs/plans/backup-restore-followups.md @@ -0,0 +1,82 @@ +# Backup & Restore Follow-ups + +Deferred work items surfaced during PR #603 (vault-backup-bundle) Macroscope review and Jarod's Loom feedback. Items here are **not** blocking #603 merge — they either belong upstack on #618 (vault-restore-flow) or are enhancements for a later PR. + +## Status legend +- **Upstack (#618)**: fix lives on `vault-restore-flow`, not `vault-backup-bundle` +- **Backlog**: enhancement, no PR assigned yet + +--- + +## 1. `ConversationsViewModel.leave()` fights `recomputeVisibleConversations()` — Upstack (#618) + +**Source:** Macroscope 🟠 High, `Convos/Conversations List/ConversationsViewModel.swift:413` + +`leave(conversation:)` mutates `conversations` directly. The `recomputeVisibleConversations()` flow (added in #618 for stale-inbox recovery) rebuilds `conversations` from `unfilteredConversations` on the next tick, so the leave is visually undone until the DB delete propagates. + +**Fix:** adopt the same `hiddenConversationIds` pattern `explodeConversation()` uses. Add the id to `hiddenConversationIds`, call `recomputeVisibleConversations()`, then clear the hidden id once the DB delete observation lands. + +**Why deferred:** `recomputeVisibleConversations` and `hiddenConversationIds` were introduced on #618. Fix belongs on that branch. + +--- + +## 2. `staleInboxIdsPublisher()` missing vault filter — Upstack (#618) + +**Source:** Macroscope 🟠 High, `ConvosCore/Sources/ConvosCore/Storage/Repositories/InboxesRepository.swift:96` + +Sibling publishers (`staleDeviceStatePublisher`, `anyInboxStalePublisher`) filter `isVault == false`. `staleInboxIdsPublisher` does not. A stale vault inbox would have its `inboxId` added to the hidden set and hide its conversations in the list, contradicting the rest of the stale-device logic which intentionally excludes vault inboxes. + +**Fix:** add `.filter(DBInbox.Columns.isVault == false)` to the query, matching the other two publishers. + +**Why deferred:** publisher and its call site were added on #618. + +--- + +## 3. Disk-space preflight for backup + UI surfacing — Backlog + +**Source:** Jarod Q2. + +Backup creates a staging directory with a full GRDB copy + per-inbox XMTP archives + the encrypted bundle. Peak usage is ~2–3x final bundle size. Today backups are tiny (~381KB for 7 convos, no images), so this is not urgent — but a user with a much larger DB on a near-full device could hit a mid-backup failure with no warning. + +**Proposed work:** +- Add a preflight check in `BackupManager.createBackup()` that reads free space on the staging volume and bails with a dedicated `BackupError.insufficientDiskSpace(required:available:)` before any work begins. +- Pick a conservative multiplier (e.g., estimate `3 * dbFileSize + fixedOverhead`). +- Surface available storage in `BackupDebugView` status rows (next to "Last backup"). +- Later: user-facing error copy when surfacing backups in settings. + +**Why deferred:** enhancement, current backup sizes make it a non-issue. Not a correctness bug. + +--- + +## 4. Backup size / compression evaluation — Backlog + +**Source:** Jarod Q4. + +Current observations: +- ~381KB for 7 conversations, text-only. +- Bundle is raw AES-GCM ciphertext, no compression. +- Media is **not** included — encrypted image refs point to external URLs with a 30-day validity. + +**Proposed work:** +- Add instrumentation (QA event or debug log) recording bundle size, DB size, and archive count per backup run. +- Collect data from dogfooding across a range of account sizes. +- Revisit compression (`Compression` framework, zlib on the tar stream before AES) if bundles exceed ~5–10MB regularly. Compression before encryption is safe here because bundles are not transmitted over an attacker-observable channel where length-based side channels matter. +- Decide whether media inclusion is in scope for a future backup version. + +**Why deferred:** current sizes don't justify the complexity. Need data first. + +--- + +## Out of scope / already handled + +- **Vault re-creation on restore** — handled on #618 via `VaultManager.reCreate` + `RestoreManager.reCreateVault`. The single-device "vault is inactive after restore" problem Louis raised is already addressed there. +- **Missing vault archive = silent data loss** — handled on #618 (`RestoreManager` throws `missingVaultArchive`). + +## Being fixed on #603 (not deferred) + +Listed here for cross-reference; these land on `vault-backup-bundle` itself: + +1. `RestoreManager` destructive-ops ordering — stage XMTP files + keychain aside, replace DB, import archives, only then delete the staged state. Covers Macroscope 🟡 `RestoreManager.swift:92` and Jarod Q1. +2. `ConvosVaultArchiveImporter.swift:47` — add `defer` cleanup on the import path. +3. `BackupManager` — fail the backup if `broadcastKeysToVault` fails (Jarod Q3, fail-loud). +4. `BackupDebugView.swift:107` — align `runAction` title with button label so the spinner renders. diff --git a/docs/plans/icloud-backup-inactive-conversation-mode.md b/docs/plans/icloud-backup-inactive-conversation-mode.md new file mode 100644 index 000000000..676e5c6ad --- /dev/null +++ b/docs/plans/icloud-backup-inactive-conversation-mode.md @@ -0,0 +1,196 @@ +# Feature: Inactive Conversation Mode (Post-Restore) + +> **Status**: Draft +> **Author**: PRD Writer Agent +> **Created**: 2026-03-18 +> **Updated**: 2026-03-18 + +## Overview + +After a user restores their account from a backup, all their conversations are in an inactive (read-only) state per the MLS protocol. XMTP requires the new installation to be re-added to each conversation by another participant before it becomes fully active. This feature surfaces that state clearly in the UI so users understand why interactions are temporarily unavailable, and automatically clears the state when the conversation becomes active again. + +## Problem Statement + +Today, a restored user sees their conversations in the list with no indication that anything is different. If they try to send a message or interact, they get unexpected behavior or silent failure. There is no explanation of why the conversation is unresponsive, and no signal about when it will recover. This creates confusion and erodes trust in the restore experience. + +## Goals + +- [ ] Give users a clear, honest explanation of why a restored conversation is temporarily limited +- [ ] Prevent accidental interaction with inactive conversations (reactions, replies, send) without blocking view access to history +- [ ] Surface the inactive state in both the conversations list and conversation detail +- [ ] Automatically clear the inactive state when the conversation becomes active again (another member sends a message) +- [ ] Reuse existing patterns and components wherever possible (Verifying state, `isPendingInvite` precedent) + +## Non-Goals + +- Not implementing any new XMTP protocol mechanism to force reactivation +- Not supporting user-triggered reactivation (this is driven by other members) +- Not adding a separate "Restored" section or filter tab to the conversations list +- Not adding visual treatment to individual historical messages from before the backup +- Not posting a group status update message when a restore happens (restore is private) +- Not handling multi-device scenarios (this is for the single-device restore path) + +## User Stories + +### As a user who just restored their account, I want to understand why I cannot interact with my conversations so that I am not confused or worried + +Acceptance criteria: +- [ ] Conversations restored from backup show a clear inactive state indicator in the list subtitle +- [ ] Opening a restored conversation shows a banner explaining the limited state +- [ ] The composer area is visually muted to signal unavailability +- [ ] Tapping any interactive element (send, reaction, swipe-to-reply) shows an alert explaining the state instead of silently failing + +### As a user whose restored conversation has become active again, I want the UI to return to normal without needing to restart the app + +Acceptance criteria: +- [ ] When another member sends a message, the inactive banner and muted composer disappear reactively +- [ ] The conversation list item returns to normal subtitle display +- [ ] No manual refresh or restart is required + +## Design Summary + +The design mirrors the existing "Verifying" state pattern (`isPendingInvite`) already present in the app. Two surfaces are affected. + +### Conversations list + +The list item subtitle shows the inactive indicator inline, following the same pattern as "Verifying" — a relative date followed by a dot separator and a short status label. The exact wording is pending designer sign-off (see Open Questions). + +### Conversation detail + +A pill/banner is pinned above the composer when the conversation is inactive. The banner contains: +- An SF Symbol icon in lava/red color (exact symbol pending — see Open Questions) +- A bold primary-color title (e.g., "History restored" or "Restored from backup" — pending) +- Secondary-color subtext describing what the user needs to do (e.g., "You can see and send new messages after another member sends a message") +- The banner is tappable and links to a "learn site" URL (URL TBD — see Open Questions) + +The composer area is visually muted: the avatar renders at reduced opacity and the text field uses an inactive color (`#d9d9d9`). The composer is not hidden — the history is fully readable. + +### Alert (interactive element interception) + +When the user taps any element that would normally trigger an interaction (send, reaction, swipe-to-reply), an alert is shown instead: +- Title: "Awaiting reconnection" +- Body: "You can see and send new messages, reactions and more after another member sends a message." +- Button: "Got it" + +## Design Decisions + +Resolved from Figma (second/refined frame) and Loom transcript: + +- **Banner title**: "History restored" +- **Banner icon**: `􀢔` — likely `clock.badge.checkmark.fill`, verify in SF Symbols app before coding +- **Banner subtext**: "You can see and send new messages after another member sends a message" +- **Composer tap**: entire composer area is intercepted — transcript says "anything you try to do... we just catch stuff that should work but can't work yet" +- **Learn site URL**: `https://learn.convos.org/` +- **Conversations list label**: "Awaiting" — short single word, consistent with "Verifying" pattern +- **Reactivation transition**: silent — banner disappears with no animation +- **Historical messages**: out of scope — Figma layer is hidden, transcript refers to fading interactive elements not messages + +## Open Questions for Courter + +- [ ] **Banner icon**: Confirm the SF Symbol name for `􀢔` (likely `clock.badge.checkmark.fill`) + +## Technical Implementation Plan + +### Phase 1: Data model + +Add an `isActive: Bool` flag surfaced on `Conversation` and stored in the existing `ConversationLocalState` table. + +`isActive()` state lives in the XMTP SQLite database — it is an MLS-level concept and is not currently stored anywhere in our GRDB. We need it in GRDB because our UI is driven entirely by GRDB `ValueObservation`: without a DB column, we have no reactive path from XMTP state → ViewModel → View. Calling `isActive()` on demand is not viable because it requires a live XMTP client and cannot drive reactive UI. + +`ConversationLocalState` is the right home for this flag — it already holds temporary UI-driving state (`isUnread`, `isPinned`, `isMuted`) in a separate table keyed by `conversationId`. The `ConversationLocalStateWriter` is the natural place to add the write path, keeping the pattern consistent. + +This means Phase 1 requires: +- A new `isActive` column on `ConversationLocalState` with a GRDB migration (default `true`, so all existing rows are unaffected) +- `isActive: Bool` surfaced on `Conversation` (read from `ConversationLocalState` join, like the other local state flags) +- `setActive(_ value: Bool, for conversationId: String)` added to `ConversationLocalStateWriterProtocol` and `ConversationLocalStateWriter` — follows the same `updateLocalState` helper pattern already used by `setUnread`, `setPinned`, `setMuted` +- A bulk variant `markAllConversationsInactive()` (no conversation ID argument) that sets `isActive = false` for every row in `ConversationLocalState` in a single write transaction — needed by `RestoreManager` which operates on all conversations at once + +The bulk write happens in `RestoreManager` right after `importConversationArchives` completes and before `finishRestore()` resumes sessions. + +**Key files:** +- `ConvosCore/Sources/ConvosCore/Storage/Models/Conversation.swift` — surface `isActive: Bool` +- `ConversationLocalState` DB record — add `isActive` column + migration +- `ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift` — add `setActive` and `markAllConversationsInactive` to protocol + implementation +- `ConvosCore/Sources/ConvosCore/Backup/RestoreManager.swift` — call `markAllConversationsInactive()` after `importConversationArchives` + +### Phase 2: Reactivation detection + +Reactivation happens when another participant **sends a message** — their XMTP client detects our new installation and issues an MLS commit re-adding it to the group. Our device receives a **welcome message** during the next `syncAllConversations` call, after which `isActive()` returns `true`. + +Detection is therefore hooked into `SyncingManager` immediately after each `syncAllConversations` call, not on individual message receipt. There are three call sites in `SyncingManager.swift`: + +1. **Initial sync** (after streams are subscribed, during `start`) +2. **Resume sync** (after `syncAllConversations` on `resume`) +3. **Discovery sync** (`requestDiscovery` / `scheduleDelayedDiscovery`) + +After each of these calls completes, query the DB for all conversations with `isActive == false`. For each, call `conversation.isActive()` on the XMTP SDK — this is a local MLS state check, no network required. If it returns `true`, call `setActive(true, for: conversationId)` to update GRDB. + +The GRDB `ValueObservation` pipeline propagates the change to the ViewModel and view reactively — no polling required, no restart needed. + +Only conversations with `isActive == false` are checked; this is a no-op for non-restored conversations. + +**Key files:** +- `ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift` — add reactivation check after each `syncAllConversations` call site (initial, resume, discovery) +- `ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift` — `setActive(true, for: conversationId)` reactivates per conversation +- `ConvosCore/Sources/ConvosCore/Storage/Repositories/ConversationRepository.swift` — add a query to fetch IDs of all conversations where `isActive == false` + +### Phase 3: Conversations list UI + +Update `ConversationsListItem` to detect `isActive` and show the inactive indicator in the subtitle, following the existing `isPendingInvite` / "Verifying" pattern. The exact label wording is pending design sign-off. + +The indicator sits in the same inline subtitle position as "Verifying": relative date, dot separator, status label. + +**Key files:** +- `Convos/Conversations List/ConversationsListItem.swift` — add `isActive` branch to subtitle rendering (alongside existing `isPendingInvite` branch) + +### Phase 4: Conversation detail UI + +Four UI concerns in `ConversationView` / `ConversationViewModel`: + +1. **Banner**: Show the pinned pill above the composer when `isActive == false`. The banner is a new view component that takes the icon, title, subtext, and a tap action (opening the learn site URL). It disappears reactively when `isActive` flips to `true`. + +2. **Muted composer**: When `isActive == false`, render the composer in its muted visual state (avatar at reduced opacity, text field in inactive color). This is a visual-only change driven by the flag. + +3. **Interactive element interception**: When `isActive == false`, tapping send, a reaction, or swipe-to-reply shows the "Awaiting reconnection" alert instead of executing the action. The interception point is in the ViewModel action handlers — check the flag and route to an alert state rather than executing. + +4. **Alert state**: Add an `isShowingReconnectionAlert: Bool` to `ConversationViewModel` (or equivalent alert-driving mechanism). The alert is dismissed with "Got it". + +**Key files:** +- `Convos/Conversation Detail/ConversationView.swift` — insert banner above composer, pass muted state to composer, wire alert +- `Convos/Conversation Detail/ConversationViewModel.swift` — expose `isActive`, add interception logic and alert state +- New view component (e.g., `InactiveBanner.swift`) — the pill/banner UI + +## Testing Strategy + +- Unit tests for `RestoreManager`: verify all conversations are marked `isActive = false` after restore +- Unit tests for `SyncingManager`: verify that after `syncAllConversations` completes, conversations with `isActive == false` are checked via `isActive()`, the flag is set to `true` when the SDK returns true, and left unchanged when the SDK returns false +- Unit tests for `Conversation` model: verify `isActive` is correctly read from DB state +- Manual testing scenarios: + 1. Complete a restore flow, open conversations list — all restored conversations show the inactive indicator + 2. Open a restored conversation — banner appears above composer, composer is muted + 3. Tap send — alert appears with "Awaiting reconnection" copy, dismisses with "Got it" + 4. Tap a reaction — same alert + 5. Swipe to reply — same alert + 6. Tap the banner — learn site URL opens + 7. Simulate another member sending a message — on the next `syncAllConversations` cycle, `isActive()` returns true, banner disappears, composer restores, list item returns to normal + 8. Open a non-restored conversation — no banner, no muted state + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| `isActive()` returns false for a healthy conversation not from a restore | Low | Flag is only ever set at restore time; non-restored conversations will never have `isActive = false` | +| Flag is never cleared if no one sends a message (quiet conversation) | Medium | Banner persists — acceptable for v1. Reactivation requires another participant to send; the user cannot force it. Could add a manual "check" action later if needed | +| `isActive()` check adds latency to sync path | Low | Local MLS state check, no network call. Only runs for conversations with `isActive == false`, which is empty after all conversations reactivate | +| Design copy/icon not finalized before implementation | Medium | Phase 3 and 4 can use placeholder strings/symbols behind a constant; finalizing design answers unblocks the final pass | +| User confusion if banner never disappears (no active members) | Medium | Copy addresses this ("after another member sends a message"); can also explore a later enhancement to detect reactivation via periodic sync | + +## References + +- Existing pattern: `isPendingInvite` in `Conversation.swift` and `ConversationsListItem.swift` +- Existing pattern: "Verifying" subtitle in `ConversationsListItem.swift` +- `ConvosCore/Sources/ConvosCore/Backup/RestoreManager.swift` — restore entry point +- `ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift` — message processing entry point +- `ConvosCore/Sources/ConvosCore/Storage/Models/Conversation.swift` — model to extend +- Related plan: `docs/plans/icloud-backup.md` — restore flow architecture +- Related plan: `docs/plans/vault-archive-backup.md` — vault restore details diff --git a/docs/plans/icloud-backup.md b/docs/plans/icloud-backup.md index 8b43681b0..00252d724 100644 --- a/docs/plans/icloud-backup.md +++ b/docs/plans/icloud-backup.md @@ -227,8 +227,10 @@ These are complementary: 3. Incremental backup strategy for v2? 4. When to expose per-conversation backup controls vs app-level default? 5. Should v2 bundle include photos only, or photos + videos? -6. QuickName/UserDefaults → GRDB migration: scope and timeline? -7. Can we reliably detect if iCloud Keychain specifically is disabled (vs just iCloud account)? +6. Can we reliably detect if iCloud Keychain specifically is disabled (vs just iCloud account)? + +### Deferred +- **Quickname backup/restore**: Quickname settings (display name, randomizer tags, profile image) are stored in UserDefaults + Documents/default-profile-image.jpg — outside the backup bundle and vault. These are not included in v1 backup/restore. A future iteration should either add quickname data to the backup bundle or sync it to the vault as a vault message. --- diff --git a/docs/plans/stale-device-detection.md b/docs/plans/stale-device-detection.md new file mode 100644 index 000000000..44de79773 --- /dev/null +++ b/docs/plans/stale-device-detection.md @@ -0,0 +1,147 @@ +# Stale Device Detection and Recovery Policy + +> **Status**: Draft +> **Author**: Louis +> **Created**: 2026-04-07 +> **Updated**: 2026-04-07 + +## Context + +After restore on another device, this installation may be revoked for one or more inboxes. + +Revocation is the destructive event. Local cleanup on this device is follow-up recovery work. + +We want a policy that: +- protects users in lost/stolen-device scenarios, +- handles partial revocation safely, +- keeps UX simple and explicit. + +## Goals + +- Detect stale installation state using authoritative network checks. +- Differentiate partial stale vs full stale. +- Use one consistent reset action for stale recovery. +- Auto-reset aggressively in full stale. +- Avoid auto-reset in partial stale. + +## Non-goals + +- Re-pairing from stale state. +- Preserving stale conversations as read-only. +- Remote wipe while device is offline. + +## State model + +Definitions: +- **Used non-vault inbox**: non-vault inbox with at least one non-unused conversation. +- **Stale used inbox**: used non-vault inbox where `isStale == true`. + +Derived states: +- `healthy`: no stale used inboxes. +- `partialStale`: some, but not all, used non-vault inboxes are stale. +- `fullStale`: all used non-vault inboxes are stale. + +Computation: +- `U = used non-vault inboxes` +- `S = stale inboxes` +- `staleUsed = U ∩ S` + +Rules: +- if `U.count == 0` -> `healthy` +- if `staleUsed.count == 0` -> `healthy` +- if `0 < staleUsed.count < U.count` -> `partialStale` +- if `staleUsed.count == U.count` -> `fullStale` + +## Detection source + +Keep existing detection source: +- `InboxStateMachine` checks `isInstallationActive()` when inbox becomes ready. +- `InboxStateMachine` checks again on app foreground. + +This uses `inboxState(refreshFromNetwork: true)` and compares current installation id to active installation list. + +## Policy + +### Healthy +- Normal app behavior. + +### Partial stale +- Hide stale conversations (existing behavior). +- Keep non-stale conversations visible. +- Do not auto-delete. +- Show persistent stale warning UI with: + - Primary action: **Continue** + - Secondary action: **Learn more** +- **Continue action behavior**: run full local reset flow (same flow as full stale cleanup). +- Copy must explicitly state consequence near the button: + - “Continuing will clear local data on this device and restart setup.” + +### Full stale +- Trigger local reset flow automatically as soon as state is confidently `fullStale`. +- No user confirmation required for auto reset. +- If auto reset fails, show blocking recovery UI with: + - Primary action: **Continue** (retry same reset flow) + - Secondary action: **Learn more** +- After successful reset, user lands in fresh app setup state. + +## Reset flow + +Reuse existing delete-all-data/reset path. Do not create a second cleanup implementation. + +Required properties: +- idempotent (safe to retry), +- clears local app data and local key material for this app install, +- leaves app in deterministic fresh-start state. + +## UX copy guidance + +### Partial stale banner +- Title: “Some conversations moved to another device” +- Body: “Continuing will clear local data on this device and restart setup.” +- Actions: `Continue`, `Learn more` + +### Full stale blocking state (fallback when auto reset fails) +- Title: “This device has been replaced” +- Body: “This device no longer has access. Continuing will clear local data and restart setup.” +- Actions: `Continue`, `Learn more` + +## Edge cases + +- **No used inboxes**: do not enter stale UX. +- **Partial -> full transition**: auto-reset only after transition to `fullStale`. +- **Network unavailable**: do not infer stale from missing network; wait for authoritative check. +- **Auto reset race with active UI**: cancel composition/sheets before reset begins. + +## Telemetry + +Emit events for: +- state transitions: healthy -> partial/full, partial -> full, stale -> healthy, +- auto reset started/succeeded/failed, +- manual continue reset started/succeeded/failed. + +## Rollout plan + +1. Add derived access state model (`healthy` / `partialStale` / `fullStale`). +2. Wire UI behavior by state. +3. Implement full-stale auto reset trigger. +4. Reuse existing reset flow for both full and partial continue actions. +5. Add unit/UI tests. + +## Test plan + +- Unit tests for state derivation matrix. +- Unit tests for transition behavior (partial to full). +- UI tests: + - partial stale shows warning and keeps non-stale conversations visible, + - continue in partial triggers reset, + - full stale auto-triggers reset, + - full stale reset failure shows blocking fallback with continue retry. +- Manual QA for lost-device scenario and mixed-state scenario. + +## References + +- `ConvosCore/Sources/ConvosCore/Inboxes/InboxStateMachine.swift` +- `ConvosCore/Sources/ConvosCore/Storage/Repositories/InboxesRepository.swift` +- `Convos/Conversations List/ConversationsViewModel.swift` +- `docs/plans/vault-re-creation-on-restore.md` +- `docs/plans/icloud-backup.md` diff --git a/docs/plans/vault-re-creation-on-restore.md b/docs/plans/vault-re-creation-on-restore.md new file mode 100644 index 000000000..89f99da52 --- /dev/null +++ b/docs/plans/vault-re-creation-on-restore.md @@ -0,0 +1,179 @@ +# Vault Re-creation on Restore + +> **Status**: Draft +> **Author**: Louis +> **Created**: 2026-04-07 +> **Updated**: 2026-04-07 + +## Problem + +After a user restores from backup on a new device (or a wiped device), all conversations **and the vault itself** are marked inactive. Regular conversations recover automatically when another member sends a message, which triggers an MLS commit re-adding the restored installation to the group. + +**The vault cannot recover this way.** The vault is the user's own private group — the only members across all their devices belong to the same user. There is no "other party" to send a message and trigger the re-addition of the restored installation. + +Concretely: + +1. Device A creates vault, adds conversations, broadcasts keys +2. Device A backs up → bundle contains vault archive + conversation archives + GRDB +3. Device A dies, or user moves to device B +4. Device B restores from backup: + - Imports vault archive → gets read-only view of old vault MLS state + - Extracts conversation keys from vault messages in GRDB + - Imports conversation archives (these will reactivate when a friend sends a message) +5. **Vault is dead.** The restored vault's MLS state lists device A's installation as a member, not device B's new installation. Device B cannot participate in the vault group. +6. Any future device the user tries to pair cannot sync via the vault, because the only active vault member is the dead installation. + +This means the backup/restore story only covers conversations. Multi-device sync via the vault is broken after any restore. + +## Goals + +- [ ] After restore, the user has a working vault on device B that can pair with future devices and sync keys +- [ ] The old vault XMTP database is preserved (not deleted), for safety, debugging, and potential manual recovery +- [ ] The new vault key is saved to iCloud Keychain (so the next device can pick it up via the existing sync mechanism) +- [ ] The old vault key in iCloud Keychain is handled gracefully (not accidentally left as the "active" key that future devices pick up first) +- [ ] Conversation keys previously extracted from the old vault archive are broadcast to the new vault so the new vault becomes the source of truth + +## Non-Goals + +- Recovering vault message history from the old vault (messages in the old vault are locked behind MLS membership we don't have) +- Migrating the vault `inboxId` — the new vault has a new inboxId by design +- Merging the old and new vault into one — they're separate MLS groups +- Trying to "reactivate" the old vault via some protocol trick — not possible per MLS design + +## Proposed flow + +### High-level sequence + +``` +Restore flow: + 1. Decrypt backup bundle (existing) + 2. Import vault archive → extract conversation keys (existing) + 3. Wipe conversation XMTP DBs + keychain (existing) + 4. Save conversation keys to keychain (existing) + 5. Replace GRDB (existing) + 6. Import conversation archives (existing) + 7. Mark all conversations inactive (existing) + 8. *** NEW: Re-create vault *** + 9. Resume sessions (existing) +``` + +### Step 8 in detail + +**8a. Revoke device A's installation from the old vault** + +Before disconnecting the old vault client, call `revokeAllOtherInstallations` on it using the old vault's signing key (still in the keychain at this point). This kills device A's installation on the old vault inboxId via the XMTP network, matching the same behavior already applied to restored conversation inboxes. + +Why this matters: without revocation, device A keeps a zombie installation on the old vault forever. Combined with the per-conversation revocations that already happen, this ensures device A is fully replaced, not just partially. + +If revocation fails (network down, key unavailable), log and continue — not fatal. The user can retry the restore or manually clean up later. + +**8b. Disconnect the old vault client** + +Tear down the XMTP client for the old vault. The bootstrap state resets to `.notStarted`. + +**8c. Clear old vault key from keychain** + +Delete the old vault key from both local and iCloud keychain stores. The old key corresponds to an inboxId whose installation is now revoked — leaving it in iCloud would cause future devices to pick up the dead vault. + +Open question: do we want to preserve the old key in a *separate* iCloud keychain service (e.g., `org.convos.vault-identity.restored`) as an audit trail? Probably not worth it for v1. + +**8d. Clear old DBInbox row** + +Delete the old vault's `DBInbox` row so the bootstrap doesn't pick it up and fail the clientId-mismatch check. + +**8e. Create new vault** + +Call `VaultManager.bootstrapVault()` with a fresh identity (new inboxId, new signing/db keys). This follows the existing "first-time creation" path: +- Generate new keys +- Create XMTP client +- Save to both local and iCloud keychain (via `VaultKeyStore`) +- Save DBInbox row with `isVault: true` + +**8f. Broadcast restored conversation keys to new vault** + +Call `VaultManager.shareAllKeys()` to publish every restored conversation key as a `DeviceKeyBundle` message in the new vault group. This makes the new vault the source of truth for future devices. + +**8g. Mark restore complete** + +State transitions to `.completed`, sessions resume. + +### Data model / storage + +No schema changes. The existing `DBInbox` table and `VaultKeyStore` cover everything. The renamed-on-disk old vault DB is purely a filesystem artifact. + +### Failure modes + +**Old vault revocation fails**: log warning, continue. Device A's vault installation lingers as a zombie, but the new vault on device B still works. Not a blocker. + +**New vault creation fails**: restore state transitions to `.failed`. User is in a half-restored state (conversations restored, no active vault). Recovery: retry. Keychain deletion should be idempotent. + +**shareAllKeys fails after new vault created**: log warning, continue. The conversations are still usable — the vault will have keys broadcast to it on the next normal sync cycle (when new messages arrive). Not fatal. + +**Edge case: no conversations in backup**: step 8f is a no-op. New vault is created empty. Fine. + +### Interaction with existing code + +- `RestoreManager.restoreFromBackup()` grows a new step after `markAllConversationsInactive()` +- `VaultManager` probably needs a `reset(preservingOldDatabase:)` or `reCreate()` method to cleanly tear down the old vault client before creating a new one +- `VaultKeyStore.deleteAll()` or `delete(inboxId:)` is called to clear the old key +- `broadcastAllKeys` already exists (called from `BackupManager.createBackup()`) — reuse it + +### What stays the same + +- The inactive conversation mode still applies to restored conversations — they're still inactive until a real message from another member arrives +- The iCloud Keychain sync flag on the new vault key is still `synchronizable: true` (from PR #626) +- Backup bundle decryption still uses all available vault keys (already handles key divergence) + +## Open Questions + +1. **Where should the old vault DB be moved to?** Options: + - Same directory, different name (e.g., `vault-xmtp.restored-20260407-153000.db3`) + - A subdirectory `restored-vaults/` for clarity + - Leave it alone — rely on `isVault = 0` in the new DBInbox row so the bootstrap picks the new one + - Leaning toward: rename with timestamp suffix so there's no ambiguity about which file is "current" + +2. **Should we delete the old vault key or rename it?** Leaning toward delete — fewer moving parts, and if we ever want to recover we still have the backup bundle. + +3. **What if the user has paired devices that are still alive?** In theory, the user should pair with an existing device to resync, not create a new vault. But we can't detect "has other active device" reliably without trying to pair. For v1, always re-create on restore. If the user has an active paired device, they can manually re-pair after restore and the new vault becomes the shared one. + - This means a user with multi-device setup who restores will have TWO active vaults (the surviving device's and the restored device's new one) until they re-pair. Not ideal but not catastrophic. + +4. **Should we show UI feedback for the vault re-creation step?** Probably yes — the restore progress UI should show "Setting up vault…" between "Importing conversations…" and "Done". + +5. **What about the backup debug panel?** The "Restore from backup" flow should transparently do this. The vault debug panel should show the new vault and the "restored" annotation on the old one. + +## Testing Strategy + +- Unit test: `RestoreManager.restoreFromBackup()` creates a new vault with new inboxId distinct from the one in the backup +- Unit test: old vault DB file is preserved on disk after restore +- Unit test: old vault key is removed from keychain after restore +- Unit test: restored conversation keys are broadcast to the new vault (verify via `VaultManager` state) +- Integration test: full restore → new vault is operational (can create new conversations, can pair a new device) +- Manual: restore on device B, observe debug panel shows a fresh vault inboxId, verify backup on device B produces a bundle with the new vault + +## Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| User with paired devices ends up with two vaults | Medium | Document the "re-pair after restore" flow; consider detecting pairing history in the future | +| Old vault DB file accumulates on disk over multiple restores | Low | Cleanup job or manual debug action to purge old files | +| New vault creation fails mid-restore | Medium | Transition to `.failed` state, surface error, support retry | +| Key broadcast to new vault fails silently | Low | Log warning, next sync cycle will re-broadcast naturally | +| User expects old vault history to be recoverable | Low | Not a goal — messages in the vault are not user-facing history anyway (they're key bundles) | + +## Sequencing + +1. **Phase 1**: `VaultManager.reCreate()` method — teardown + new vault creation, with old DB rename +2. **Phase 2**: Wire into `RestoreManager.restoreFromBackup()` after `markAllConversationsInactive()` +3. **Phase 3**: Broadcast restored keys to new vault +4. **Phase 4**: UI feedback in restore progress (cosmetic) +5. **Phase 5**: Tests + +Estimated: 1–2 days for Phases 1–3, plus testing. + +## References + +- `docs/plans/convos-vault.md` — overall vault architecture (acknowledges this limitation) +- `docs/plans/icloud-backup.md` — backup/restore flow +- `docs/plans/icloud-backup-inactive-conversation-mode.md` — inactive mode for conversations +- `ConvosCore/Sources/ConvosCore/Vault/VaultManager.swift` — existing vault lifecycle +- `ConvosCore/Sources/ConvosCore/Backup/RestoreManager.swift` — restore entry point