diff --git a/.github/workflows/convoscore-tests.yml b/.github/workflows/convoscore-tests.yml index e299026ca..46a8a31e7 100644 --- a/.github/workflows/convoscore-tests.yml +++ b/.github/workflows/convoscore-tests.yml @@ -44,6 +44,15 @@ jobs: - name: Run unit tests run: ./ci/run-tests.sh --unit + - name: Upload unit test logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-logs + path: ci-test-logs/ + retention-days: 7 + if-no-files-found: ignore + deploy-backend: name: Deploy XMTP Backend runs-on: ubuntu-latest @@ -109,6 +118,7 @@ jobs: with: name: integration-test-logs path: | + ci-test-logs/ ConvosCore/.build/debug/*.log xmtp-test-logs/ retention-days: 7 diff --git a/.github/workflows/firebase-pr.yml b/.github/workflows/firebase-pr.yml index e1664d8b2..c5b7de67f 100644 --- a/.github/workflows/firebase-pr.yml +++ b/.github/workflows/firebase-pr.yml @@ -34,7 +34,7 @@ on: branches: ["main", "dev"] # `labeled` lets contributors trigger a build by adding the # `adhoc-build` label without re-pushing. Filtered in the job's `if:`. - types: [opened, synchronize, reopened, labeled] + types: [opened, reopened, synchronize, labeled] concurrency: group: firebase-pr-${{ github.event.pull_request.number || github.ref }} diff --git a/.gitignore b/.gitignore index bd0b27fce..08c5bd00c 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,11 @@ playground.xcworkspace .build/ +# CI test logs captured by ci/run-tests.sh (uploaded as workflow artifacts). +ci-test-logs/ +# XMTP integration test logs (CONVOS_TEST_XMTP_LOG_DIR in CI). +xmtp-test-logs/ + # SwiftLint local cache, used by `swiftlint --cache-path .swiftlint-cache` # and the GitHub Actions cache. .swiftlint-cache/ diff --git a/Convos/Contacts/ContactRowView.swift b/Convos/Contacts/ContactRowView.swift index 53085b886..b67db6801 100644 --- a/Convos/Contacts/ContactRowView.swift +++ b/Convos/Contacts/ContactRowView.swift @@ -1,3 +1,4 @@ +import Combine import ConvosCore import CryptoKit import Foundation @@ -78,14 +79,95 @@ struct ContactAvatarView: View { let contact: Contact var body: some View { - AvatarView( + InboxProfileAvatarView( + inboxId: contact.inboxId, fallbackName: contact.resolvedDisplayName, - cacheableObject: contact, - placeholderImage: nil, placeholderEmoji: contact.profileEmoji, + agentVerification: contact.agentVerification ?? .unverified, + // Synthetic contacts (suggested agents, agent-share placeholders) + // carry their own avatar but have no canonical profile row; keep + // rendering that image instead of only reading the repository. + fallbackCacheable: contact + ) + } +} + +/// Avatar keyed by inbox id that renders the canonical avatar from the unified +/// profile system (newest avatar across every conversation) and stays in sync +/// with profile changes via `ProfilesRepository.profilePublisher`. Sharing the +/// `Profile.imageCacheIdentifier == inboxId` key means this shares the +/// encrypted-image cache with the chat surfaces (message bubbles, members +/// list), so an update flowing through the repository invalidates once and +/// every consumer picks up the new image. +/// +/// When no repository is injected (previews / uninjected subtrees) the view +/// falls through to the placeholder for the empty profile rather than crashing. +struct InboxProfileAvatarView: View { + let inboxId: String + let fallbackName: String + let placeholderEmoji: String? + let agentVerification: AgentVerification + /// Rendered when the canonical profile has no avatar yet - including a + /// synthetic contact (suggested agent, agent-share placeholder) that carries + /// its own avatar but has no profile row. Keeps the shared-cache benefit for + /// real profiles while preserving the caller's own image instead of dropping + /// to the placeholder. + var fallbackCacheable: (any ImageCacheable)? + + @Environment(\.profilesRepository) private var repository: ProfilesRepository? + @State private var renderedProfile: Profile? + + var body: some View { + AvatarView( + fallbackName: fallbackName, + cacheableObject: resolvedCacheable, + placeholderImage: nil, + placeholderEmoji: placeholderEmoji, placeholderImageName: nil, - agentVerification: contact.agentVerification ?? .unverified + agentVerification: agentVerification ) + .task(id: inboxId) { + await observeProfile(for: inboxId) + } + } + + /// Prefer the canonical profile's avatar (shared image cache with chat + /// surfaces). When it has no image, fall back to the caller's own cacheable + /// so a synthetic contact still renders its provided avatar. + private var resolvedCacheable: any ImageCacheable { + if let renderedProfile, renderedProfile.imageCacheURL != nil { + return renderedProfile + } + if let fallbackCacheable, fallbackCacheable.imageCacheURL != nil { + return fallbackCacheable + } + return renderedProfile ?? Profile.empty(inboxId: inboxId) + } + + /// Subscribes to the unified profile for `subscribedInboxId` and maps each + /// emission into a `Profile` for the shared avatar cache. Awaiting the + /// publisher's async `values` sequence keeps the subscription tied to the + /// view's lifetime (cancelled when the task is torn down or `inboxId` + /// changes) without the resubscription churn a recomputed `onReceive` + /// publisher would cause. No repository (previews) leaves the placeholder. + private func observeProfile(for subscribedInboxId: String) async { + guard let repository else { return } + let stream = repository.profilePublisher(inboxId: subscribedInboxId).values + for await unified in stream { + let avatar: Avatar? = unified.displayAvatar(for: nil) + let profile = Profile( + inboxId: unified.inboxId, + conversationId: "", + name: unified.name, + avatar: avatar?.url, + avatarSalt: avatar?.salt, + avatarNonce: avatar?.nonce, + avatarKey: avatar?.key, + isAgent: unified.isAgent, + metadata: unified.metadata + ) + renderedProfile = profile + } } } diff --git a/Convos/Contacts/MemberNameOverride.swift b/Convos/Contacts/MemberNameOverride.swift index 847c324f3..75fc19cf8 100644 --- a/Convos/Contacts/MemberNameOverride.swift +++ b/Convos/Contacts/MemberNameOverride.swift @@ -2,24 +2,20 @@ import ConvosCore import Foundation import SwiftUI -/// SwiftUI environment carrier for the inbox-to-contact override -/// closure. Each surface that wants contact-authoritative rendering -/// injects the closure once near the top of its view tree, typically -/// `.memberContactOverride(contactsRepository.contact(for:))`. -/// Descendants read `@Environment(\.memberContactOverride)` for richer -/// substitutions (avatar, encrypted image keys, agent verification) or -/// `@Environment(\.memberNameOverride)` for the name-only adapter that -/// ConvosCore APIs (`Conversation.computedDisplayName(memberNameOverride:)`, -/// `ConversationUpdate.summary(memberNameOverride:)`) accept. +/// SwiftUI environment carrier for the (now deprecated) inbox-to-contact +/// override closure. Identity - display name and image - is sourced +/// authoritatively from the `Profile` database; contact data never overrides +/// it. The resolver is always the no-op returning `nil` for every inbox, so +/// every consumer falls through to the member `Profile`. /// -/// The default is a no-op resolver returning `nil` for every inbox, which -/// preserves the legacy per-conversation profile behavior in previews -/// and any uninjected surface. The lookup itself lives on -/// `ContactsRepositoryProtocol.contact(for:)`. +/// The environment key, the `memberNameOverride` adapter, and the +/// `.memberContactOverride(_:)` modifier are retained (the modifier ignoring +/// its argument) so existing call sites compile until the override plumbing is +/// removed. private struct MemberContactOverrideKey: EnvironmentKey { // `@Sendable` because SwiftUI's `EnvironmentKey.defaultValue` is // required to be `Sendable` under strict concurrency, and the - // override is read from many isolation domains (main-actor SwiftUI + // resolver is read from many isolation domains (main-actor SwiftUI // bodies, background contact fetches). static let defaultValue: @Sendable (String) -> Contact? = { _ in nil } } @@ -30,29 +26,18 @@ extension EnvironmentValues { set { self[MemberContactOverrideKey.self] = newValue } } - /// Name-only adapter derived from `memberContactOverride`. Returns - /// the contact's display name when present and non-empty, otherwise - /// `nil` so ConvosCore APIs fall back to per-conversation profile - /// names. Read-only - inject via `.memberContactOverride(_:)` and - /// this adapter follows automatically. + /// Deprecated no-op name adapter. Always returns `nil` so ConvosCore APIs + /// source names from the `Profile` database. var memberNameOverride: @Sendable (String) -> String? { - let resolver = memberContactOverride - return { inboxId in - guard let name = resolver(inboxId)?.displayName, !name.isEmpty else { - return nil - } - return name - } + { _ in nil } } } extension View { - /// Injects an inbox-to-contact override closure into the SwiftUI - /// environment so descendant surfaces (conversation list, pinned - /// tiles, info previews, system-message cells, ...) substitute the - /// user's contact data (name and avatar) in place of the - /// per-conversation profile data. + /// Deprecated no-op. Contact data no longer overrides `Profile` identity, so + /// this ignores the resolver and leaves the environment at its `nil` default. + /// Retained so call sites compile until the plumbing is removed. func memberContactOverride(_ resolver: @escaping @Sendable (String) -> Contact?) -> some View { - environment(\.memberContactOverride, resolver) + self } } diff --git a/Convos/Contacts/Profile+ContactOverlay.swift b/Convos/Contacts/Profile+ContactOverlay.swift index ed16e12b3..bb4b70d6d 100644 --- a/Convos/Contacts/Profile+ContactOverlay.swift +++ b/Convos/Contacts/Profile+ContactOverlay.swift @@ -31,8 +31,7 @@ extension Contact { /// rendering (`displayAvatar(for:)`) in the ProfilesRepository refactor, when /// this whole stopgap is removed. /// - /// Interim stopgap: remove once identity resolves from ProfilesRepository - /// (see docs/plans/2026-06-29-profile-table-implementation.md, section 10.1). + /// Interim stopgap: remove once identity resolves from ProfilesRepository. static func liveOverride(member: Profile, stored: Contact?) -> Contact { Contact( inboxId: member.inboxId, @@ -51,80 +50,25 @@ extension Contact { ) } - /// Builds a member-aware contact resolver: freshens the current conversation - /// member's avatar image (the source the message bubble uses) over the lagging - /// stored contact, falling back to the stored contact for non-members. Name - /// and other identity fields are left as the stored contact's. See - /// `liveOverride`. Interim stopgap; see - /// docs/plans/2026-06-29-profile-table-implementation.md, section 10.1. + /// Deprecated no-op. Identity (name and image) is now sourced authoritatively + /// from the `Profile` database; contact data never overrides it. Returns a + /// resolver that yields `nil` for every inbox so callers fall through to the + /// member `Profile`. Retained (ignoring its arguments) so call sites compile + /// until the contact-override plumbing is fully removed. static func memberAwareResolver( members: [ConversationMember], contactLookup: @escaping @Sendable (String) -> Contact? ) -> @Sendable (String) -> Contact? { - let memberProfiles: [String: Profile] = Dictionary( - members.map { ($0.profile.inboxId, $0.profile) }, - uniquingKeysWith: { current, _ in current } - ) - return { inboxId in - let stored = contactLookup(inboxId) - guard let member = memberProfiles[inboxId] else { return stored } - return Contact.liveOverride(member: member, stored: stored) - } + { _ in nil } } } extension Profile { - /// Returns a new `Profile` with `name` and `avatar*` substituted - /// from the supplied contact when - and only when - the contact - /// actually carries the corresponding data. A name-only contact - /// does NOT clobber the per-conversation avatar, and a contact - /// with neither field does not clobber a perfectly good per- - /// conversation profile. `conversationId`, `isAgent`, - /// `imageSourceContentDigest`, and `metadata` are always preserved - /// from the original. - /// - /// The chat layer uses this so a system-message or avatar row - /// shows the contact's name and photo when the inbox is a known - /// contact whose per-conversation profile has not landed yet, - /// without regressing rows where the per-conversation profile is - /// already richer than the contact entry. + /// Deprecated no-op. Identity (name and image) is sourced authoritatively + /// from the `Profile` database; contact data never overrides it, so this + /// returns the profile unchanged. Retained so call sites compile until the + /// contact-override plumbing is removed. func overlaying(contact: Contact) -> Profile { - let resolvedName: String? - if let contactName = contact.displayName, !contactName.isEmpty { - resolvedName = contactName - } else { - resolvedName = name - } - // Avatar fields move as a set: a contact without an avatar URL - // has no usable encryption material either, so substituting - // any one of them in isolation would leave the cache key - // pointing at an inaccessible blob. - let resolvedAvatar: String? - let resolvedAvatarSalt: Data? - let resolvedAvatarNonce: Data? - let resolvedAvatarKey: Data? - if contact.avatarURL != nil { - resolvedAvatar = contact.avatarURL - resolvedAvatarSalt = contact.avatarSalt - resolvedAvatarNonce = contact.avatarNonce - resolvedAvatarKey = contact.avatarKey - } else { - resolvedAvatar = avatar - resolvedAvatarSalt = avatarSalt - resolvedAvatarNonce = avatarNonce - resolvedAvatarKey = avatarKey - } - return Profile( - inboxId: inboxId, - conversationId: conversationId, - name: resolvedName, - avatar: resolvedAvatar, - avatarSalt: resolvedAvatarSalt, - avatarNonce: resolvedAvatarNonce, - avatarKey: resolvedAvatarKey, - isAgent: isAgent, - imageSourceContentDigest: imageSourceContentDigest, - metadata: metadata - ) + self } } diff --git a/Convos/Conversation Detail/ConversationView.swift b/Convos/Conversation Detail/ConversationView.swift index 0c3a13a42..add3bd73d 100644 --- a/Convos/Conversation Detail/ConversationView.swift +++ b/Convos/Conversation Detail/ConversationView.swift @@ -524,57 +524,6 @@ struct ConversationView: View { isKeyboardVisible ? 0.0 : 24.0 } - private var metricsObserversPart1: MetricsObserversPart1 { - MetricsObserversPart1( - presentingConversationSettings: viewModel.presentingConversationSettings, - presentingProfileSettings: viewModel.presentingProfileSettings, - presentingShareView: viewModel.presentingShareView, - presentingConversationForked: viewModel.presentingConversationForked, - presentingExplodedInviteInfo: viewModel.presentingExplodedInviteInfo, - presentingAgentsIntro: viewModel.presentingAgentsIntro, - presentingPaywall: viewModel.presentingPaywall, - showingAgentsInfo: showingAgentsInfo, - showingLockedInfo: showingLockedInfo, - onConversationSettingsChanged: handleConversationSettingsChanged(from:to:), - onProfileSettingsChanged: handleProfileSettingsChanged(from:to:), - onShareViewChanged: handleShareViewChanged(from:to:), - onConversationForkedChanged: handleConversationForkedChanged(from:to:), - onExplodedInviteInfoChanged: handleExplodedInviteInfoChanged(from:to:), - onAgentsIntroChanged: handleAgentsIntroChanged(from:to:), - onPaywallChanged: handlePaywallChanged(from:to:), - onAgentsInfoChanged: handleAgentsInfoChanged(from:to:), - onLockedInfoChanged: handleLockedInfoChanged(from:to:) - ) - } - - private var metricsObserversPart3: MetricsObserversPart3 { - MetricsObserversPart3( - presentingProfileForMember: viewModel.presentingProfileForMember, - presentingContactForAgentShare: viewModel.presentingContactForAgentShare, - presentingReactionsForMessage: viewModel.presentingReactionsForMessage, - presentingThinkingDetail: viewModel.presentingThinkingDetail, - onMemberProfileChanged: handleMemberProfileChanged(from:to:), - onAgentShareContactChanged: handleAgentShareContactChanged(from:to:), - onReactionsChanged: handleReactionsChanged(from:to:), - onThinkingDetailChanged: handleThinkingDetailChanged(from:to:) - ) - } - - private var metricsObserversPart2: MetricsObserversPart2 { - MetricsObserversPart2( - showingFullInfo: showingFullInfo, - presentingPhotosInfo: viewModel.presentingPhotosInfoSheet, - presentingAgentBuilder: viewModel.presentingAgentBuilder != nil, - presentingNewConvoForInvite: viewModel.presentingNewConversationForInvite != nil, - presentingAddFromContactsPicker: presentingAddFromContactsPicker, - onFullInfoChanged: handleFullInfoChanged(from:to:), - onPhotosInfoChanged: handlePhotosInfoChanged(from:to:), - onAgentBuilderChanged: handleAgentBuilderChanged(from:to:), - onNewConvoInviteChanged: handleNewConvoInviteChanged(from:to:), - onAddFromContactsChanged: handleAddFromContactsChanged(from:to:) - ) - } - var body: some View { ConversationPager( selectedPage: $pagerSelectedPage, @@ -894,3 +843,56 @@ extension ConversationView { ) } } + +extension ConversationView { + private var metricsObserversPart1: MetricsObserversPart1 { + MetricsObserversPart1( + presentingConversationSettings: viewModel.presentingConversationSettings, + presentingProfileSettings: viewModel.presentingProfileSettings, + presentingShareView: viewModel.presentingShareView, + presentingConversationForked: viewModel.presentingConversationForked, + presentingExplodedInviteInfo: viewModel.presentingExplodedInviteInfo, + presentingAgentsIntro: viewModel.presentingAgentsIntro, + presentingPaywall: viewModel.presentingPaywall, + showingAgentsInfo: showingAgentsInfo, + showingLockedInfo: showingLockedInfo, + onConversationSettingsChanged: handleConversationSettingsChanged(from:to:), + onProfileSettingsChanged: handleProfileSettingsChanged(from:to:), + onShareViewChanged: handleShareViewChanged(from:to:), + onConversationForkedChanged: handleConversationForkedChanged(from:to:), + onExplodedInviteInfoChanged: handleExplodedInviteInfoChanged(from:to:), + onAgentsIntroChanged: handleAgentsIntroChanged(from:to:), + onPaywallChanged: handlePaywallChanged(from:to:), + onAgentsInfoChanged: handleAgentsInfoChanged(from:to:), + onLockedInfoChanged: handleLockedInfoChanged(from:to:) + ) + } + + private var metricsObserversPart3: MetricsObserversPart3 { + MetricsObserversPart3( + presentingProfileForMember: viewModel.presentingProfileForMember, + presentingContactForAgentShare: viewModel.presentingContactForAgentShare, + presentingReactionsForMessage: viewModel.presentingReactionsForMessage, + presentingThinkingDetail: viewModel.presentingThinkingDetail, + onMemberProfileChanged: handleMemberProfileChanged(from:to:), + onAgentShareContactChanged: handleAgentShareContactChanged(from:to:), + onReactionsChanged: handleReactionsChanged(from:to:), + onThinkingDetailChanged: handleThinkingDetailChanged(from:to:) + ) + } + + private var metricsObserversPart2: MetricsObserversPart2 { + MetricsObserversPart2( + showingFullInfo: showingFullInfo, + presentingPhotosInfo: viewModel.presentingPhotosInfoSheet, + presentingAgentBuilder: viewModel.presentingAgentBuilder != nil, + presentingNewConvoForInvite: viewModel.presentingNewConversationForInvite != nil, + presentingAddFromContactsPicker: presentingAddFromContactsPicker, + onFullInfoChanged: handleFullInfoChanged(from:to:), + onPhotosInfoChanged: handlePhotosInfoChanged(from:to:), + onAgentBuilderChanged: handleAgentBuilderChanged(from:to:), + onNewConvoInviteChanged: handleNewConvoInviteChanged(from:to:), + onAddFromContactsChanged: handleAddFromContactsChanged(from:to:) + ) + } +} diff --git a/Convos/Conversation Detail/ConversationViewModel.swift b/Convos/Conversation Detail/ConversationViewModel.swift index a46f39a9e..1c5c97d43 100644 --- a/Convos/Conversation Detail/ConversationViewModel.swift +++ b/Convos/Conversation Detail/ConversationViewModel.swift @@ -1320,7 +1320,6 @@ class ConversationViewModel: Identifiable, Hashable { // swiftlint:disable:this self.reactionWriter = messagingService.reactionWriter() self.readReceiptWriter = messagingService.readReceiptWriter() - let myProfileWriter = conversationStateManager.myProfileWriter let myProfileRepository = conversationRepository.myProfileRepository // MyProfileViewModel fills its "empty" profile with the current user's // inboxId. In single-inbox mode that's always the singleton; read it @@ -1328,7 +1327,7 @@ class ConversationViewModel: Identifiable, Hashable { // swiftlint:disable:this let currentUserInboxId = conversation.members.first(where: { $0.isCurrentUser })?.profile.inboxId ?? "" myProfileViewModel = .init( inboxId: currentUserInboxId, - myProfileWriter: myProfileWriter, + messagingService: messagingService, myProfileRepository: myProfileRepository ) @@ -1418,12 +1417,11 @@ class ConversationViewModel: Identifiable, Hashable { // swiftlint:disable:this self.reactionWriter = messagingService.reactionWriter() self.readReceiptWriter = messagingService.readReceiptWriter() - let myProfileWriter = conversationStateManager.myProfileWriter let myProfileRepository = conversationStateManager.draftConversationRepository.myProfileRepository let draftCurrentUserInboxId = conversation.members.first(where: { $0.isCurrentUser })?.profile.inboxId ?? "" myProfileViewModel = .init( inboxId: draftCurrentUserInboxId, - myProfileWriter: myProfileWriter, + messagingService: messagingService, myProfileRepository: myProfileRepository ) diff --git a/Convos/Conversations List/ConversationsViewModel.swift b/Convos/Conversations List/ConversationsViewModel.swift index f63a953e4..2628b446b 100644 --- a/Convos/Conversations List/ConversationsViewModel.swift +++ b/Convos/Conversations List/ConversationsViewModel.swift @@ -383,6 +383,15 @@ final class ConversationsViewModel { imageAssetIdentifier: imageAssetIdentifier, metadata: nil ) + // Propagate the adopted profile through the canonical + // repository so it fans out to every conversation via + // the durable publisher. No image bytes are available on + // the adoption path, so only the display name is sent. + try await session.messagingService().profilesRepository().publishMyProfile( + displayName: displayName, + avatarBytes: nil, + priorityConversationId: nil + ) seeded = true } catch { Log.warning("Pairing: failed to seed DBMyProfile after adoption: \(error)") diff --git a/Convos/MainTabView.swift b/Convos/MainTabView.swift index 7f1ba13bb..6a5e48a0e 100644 --- a/Convos/MainTabView.swift +++ b/Convos/MainTabView.swift @@ -279,6 +279,7 @@ struct MainTabView: View { var body: some View { bodyCore + .profilesRepository(conversationsViewModel.session.messagingServiceSync().profilesRepository()) .environment(promptHints) .task { await promptHints.loadOnLaunch() diff --git a/Convos/Profile/MyProfileViewModel.swift b/Convos/Profile/MyProfileViewModel.swift index 08758b444..c677accc3 100644 --- a/Convos/Profile/MyProfileViewModel.swift +++ b/Convos/Profile/MyProfileViewModel.swift @@ -5,7 +5,7 @@ import SwiftUI @MainActor @Observable class MyProfileViewModel { - private let myProfileWriter: any MyProfileWriterProtocol + private let messagingService: any MessagingServiceProtocol private let myProfileRepository: any MyProfileRepositoryProtocol private(set) var profile: Profile private var cancellables: Set = [] @@ -28,11 +28,11 @@ class MyProfileViewModel { init( inboxId: String, - myProfileWriter: any MyProfileWriterProtocol, + messagingService: any MessagingServiceProtocol, myProfileRepository: any MyProfileRepositoryProtocol ) { self.profile = .empty(inboxId: inboxId) - self.myProfileWriter = myProfileWriter + self.messagingService = messagingService self.myProfileRepository = myProfileRepository do { @@ -114,12 +114,16 @@ class MyProfileViewModel { editingDisplayName = displayName updateDisplayNameTask?.cancel() beginUpdate() - let unsafeWriter = myProfileWriter + let repository = messagingService.profilesRepository() updateDisplayNameTask = Task { [weak self] in guard self != nil else { return } defer { Task { @MainActor [weak self] in self?.endUpdate() } } do { - try await unsafeWriter.update(displayName: displayName, conversationId: conversationId) + try await repository.publishMyProfile( + displayName: displayName, + avatarBytes: nil, + priorityConversationId: conversationId + ) } catch { Log.error("Error updating profile display name: \(error.localizedDescription)") } @@ -130,13 +134,13 @@ class MyProfileViewModel { updateMetadataTask?.cancel() beginUpdate() let displayNameTask = updateDisplayNameTask - let unsafeWriter = myProfileWriter + let repository = messagingService.profilesRepository() updateMetadataTask = Task { [weak self] in guard self != nil else { return } defer { Task { @MainActor [weak self] in self?.endUpdate() } } await displayNameTask?.value do { - try await unsafeWriter.update(metadata: profileMetadata, conversationId: conversationId) + try await repository.publishMyProfileMetadata(profileMetadata) } catch { Log.error("Error updating profile metadata: \(error.localizedDescription)") } @@ -150,7 +154,8 @@ class MyProfileViewModel { updateImageTask?.cancel() beginUpdate() let displayNameTask = updateDisplayNameTask - let unsafeWriter = myProfileWriter + let repository = messagingService.profilesRepository() + let avatarBytes = profileImage.jpegData(compressionQuality: 1.0) updateImageTask = Task { [weak self] in guard self != nil else { return } defer { Task { @MainActor [weak self] in self?.endUpdate() } } @@ -158,7 +163,11 @@ class MyProfileViewModel { // so the avatar update reads the profile with the correct name await displayNameTask?.value do { - try await unsafeWriter.update(avatar: profileImage, conversationId: conversationId) + try await repository.publishMyProfile( + displayName: nil, + avatarBytes: avatarBytes, + priorityConversationId: conversationId + ) } catch { Log.error("Error updating profile image: \(error.localizedDescription)") } @@ -249,7 +258,7 @@ extension MyProfileViewModel { static var mock: MyProfileViewModel { return .init( inboxId: "mock-inbox-id", - myProfileWriter: MockMyProfileWriter(), + messagingService: MockMessagingService(), myProfileRepository: MockMyProfileRepository() ) } diff --git a/Convos/Profile/ProfileSettingsViewModel.swift b/Convos/Profile/ProfileSettingsViewModel.swift index f6d59d035..e9c014112 100644 --- a/Convos/Profile/ProfileSettingsViewModel.swift +++ b/Convos/Profile/ProfileSettingsViewModel.swift @@ -158,13 +158,18 @@ class ProfileSettingsViewModel { // stored name rather than writing nil, and the field is restored so the // UI reflects that the empty value was rejected. First-time users with // no stored name are unaffected (there is nothing to preserve). - let resolvedName: String? + let rawName: String? if trimmedName.isEmpty, let loadedDisplayName { - resolvedName = loadedDisplayName + rawName = loadedDisplayName editingDisplayName = loadedDisplayName } else { - resolvedName = trimmedName.isEmpty ? nil : trimmedName + rawName = trimmedName.isEmpty ? nil : trimmedName } + // Truncate once so `writer.save` (which truncates internally) and the + // canonical publish send the same name; otherwise a name over the limit + // stores truncated locally but publishes the full string, so other + // conversations show a different name than the one saved. + let resolvedName: String? = rawName.map { String($0.prefix(NameLimits.maxDisplayNameLength)) } let imageData = profileImage?.jpegData(compressionQuality: 1.0) let assetIdentifier = imageData == nil ? nil : profileImageAssetIdentifier try await writer.save( @@ -173,6 +178,14 @@ class ProfileSettingsViewModel { imageAssetIdentifier: assetIdentifier, metadata: loadedMetadata ) + // Propagate the global profile through the canonical repository so it + // fans out to every conversation via the durable publisher. The + // `writer.save` above still owns the edit-side form model (DBMyProfile). + try await session?.messagingServiceSync().profilesRepository().publishMyProfile( + displayName: resolvedName, + avatarBytes: imageData, + priorityConversationId: nil + ) // Arm the empty-save guard immediately. `loadedDisplayName` is otherwise // only refreshed by the async profile observation, so a first-time user // who saves a name and then clears + saves again before that fires would diff --git a/Convos/Profile/ProfilesRepositoryEnvironment.swift b/Convos/Profile/ProfilesRepositoryEnvironment.swift new file mode 100644 index 000000000..494c7f951 --- /dev/null +++ b/Convos/Profile/ProfilesRepositoryEnvironment.swift @@ -0,0 +1,24 @@ +import ConvosCore +import SwiftUI + +/// Injects the canonical `ProfilesRepository` into the SwiftUI environment so +/// avatar surfaces keyed by inbox id (see `InboxProfileAvatarView`) can +/// subscribe to unified profile changes without threading the session through +/// every view. Defaults to nil so previews and any uninjected subtree render a +/// placeholder rather than crashing. +private struct ProfilesRepositoryKey: EnvironmentKey { + static let defaultValue: ProfilesRepository? = nil +} + +extension EnvironmentValues { + var profilesRepository: ProfilesRepository? { + get { self[ProfilesRepositoryKey.self] } + set { self[ProfilesRepositoryKey.self] = newValue } + } +} + +extension View { + func profilesRepository(_ repository: ProfilesRepository?) -> some View { + environment(\.profilesRepository, repository) + } +} diff --git a/Convos/Shared Views/AvatarView.swift b/Convos/Shared Views/AvatarView.swift index a9e1a5cc1..3e59a07c4 100644 --- a/Convos/Shared Views/AvatarView.swift +++ b/Convos/Shared Views/AvatarView.swift @@ -124,6 +124,19 @@ struct ConversationAvatarView: View { Image(uiImage: conversationImage) .resizable() .scaledToFill() + } else if case let .profile(profile, verification) = conversation.avatarType { + // DM / single-member conversation: observe the member's canonical + // profile by inboxId so the list avatar updates reactively when + // their photo changes anywhere, instead of loading the stale + // snapshot URL captured on the conversation. Shares the per-inbox + // image cache with the chat surfaces. + InboxProfileAvatarView( + inboxId: profile.inboxId, + fallbackName: profile.displayName, + placeholderEmoji: dmPlaceholderEmoji(profile: profile, verification: verification), + agentVerification: verification, + fallbackCacheable: conversation + ) } else if let image = cachedImage { Image(uiImage: image) .resizable() @@ -137,6 +150,20 @@ struct ConversationAvatarView: View { .cachedImage(for: conversation, into: $cachedImage) } + /// Placeholder for a DM member with no avatar, mirroring the `.profile` + /// branch of `fallbackContent`: their own emoji, else the conversation's + /// default emoji for an unverified member, else nil so a verified agent + /// falls to a monogram. + private func dmPlaceholderEmoji(profile: Profile, verification: AgentVerification) -> String? { + if let emoji = profile.profileEmoji, !emoji.isEmpty { + return emoji + } + if verification == .unverified { + return conversation.defaultEmoji + } + return nil + } + @ViewBuilder private func pendingAgentIdentityAvatar(_ identity: PendingAgentAvatarIdentity) -> some View { if let emoji = identity.emoji, !emoji.isEmpty { diff --git a/ConvosCore/Sources/ConvosCore/AgentBuilder/AgentBuilderConnectionGrantReplayer.swift b/ConvosCore/Sources/ConvosCore/AgentBuilder/AgentBuilderConnectionGrantReplayer.swift index 4b068d7cd..eef9794ad 100644 --- a/ConvosCore/Sources/ConvosCore/AgentBuilder/AgentBuilderConnectionGrantReplayer.swift +++ b/ConvosCore/Sources/ConvosCore/AgentBuilder/AgentBuilderConnectionGrantReplayer.swift @@ -318,11 +318,18 @@ public final class AgentBuilderConnectionGrantReplayer: Sendable { } } - private static func verifiedAgentInboxIds(db: Database, conversationId: String) throws -> [String] { - let profileRows: [DBMemberProfile] = try DBMemberProfile - .filter(DBMemberProfile.Columns.conversationId == conversationId) + // Internal (not private) so the canonical-read behavior can be unit-tested + // against a seeded database. + static func verifiedAgentInboxIds(db: Database, conversationId: String) throws -> [String] { + // The conversation roster is the source of truth for membership; canonical + // identity (agent kind + verification) lives in `DBProfile`. A verified + // agent learned only from a streamed profile has no legacy `DBMemberProfile` + // row, so read canonical. + let memberInboxIds = try DBConversationMember + .filter(DBConversationMember.Columns.conversationId == conversationId) .fetchAll(db) - return profileRows + .map(\.inboxId) + return try DBProfile.fetchAll(db, inboxIds: memberInboxIds) .filter { $0.isAgent && $0.agentVerification.isVerified } .map(\.inboxId) } diff --git a/ConvosCore/Sources/ConvosCore/Assets/AssetRenewalManager.swift b/ConvosCore/Sources/ConvosCore/Assets/AssetRenewalManager.swift index 24aaa93ec..fef9620f0 100644 --- a/ConvosCore/Sources/ConvosCore/Assets/AssetRenewalManager.swift +++ b/ConvosCore/Sources/ConvosCore/Assets/AssetRenewalManager.swift @@ -144,13 +144,15 @@ public actor AssetRenewalManager { for asset in assets { switch asset { case let .profileAvatar(url, _, _, _): - // Update ALL profiles with this avatar URL (same person may appear in multiple conversations) - let profiles = try DBMemberProfile - .filter(DBMemberProfile.Columns.avatar == url) + // Stamp lastRenewed on all canonical avatar slots with this URL + // (same person may appear in multiple conversations). updatedAt + // is left untouched so renewal does not perturb merge recency. + var avatars = try DBProfileAvatar + .filter(DBProfileAvatar.Columns.url == url) .fetchAll(db) - for var profile in profiles { - profile = profile.with(avatarLastRenewed: now) - try profile.save(db) + for i in avatars.indices { + avatars[i].lastRenewed = now + try avatars[i].save(db) } case let .groupImage(url, _, _): // Update ALL conversations with this image URL (for consistency with ExpiredAssetRecoveryHandler) diff --git a/ConvosCore/Sources/ConvosCore/Assets/AssetRenewalURLCollector.swift b/ConvosCore/Sources/ConvosCore/Assets/AssetRenewalURLCollector.swift index 311f82e83..ebd030610 100644 --- a/ConvosCore/Sources/ConvosCore/Assets/AssetRenewalURLCollector.swift +++ b/ConvosCore/Sources/ConvosCore/Assets/AssetRenewalURLCollector.swift @@ -41,21 +41,21 @@ public struct AssetRenewalURLCollector { var seenURLs: Set = [] // 1. My profile avatars (with conversationId for re-upload) - let profiles = try DBMemberProfile - .filter(allInboxIds.contains(DBMemberProfile.Columns.inboxId)) - .filter(DBMemberProfile.Columns.avatar != nil) + let avatars = try DBProfileAvatar + .filter(allInboxIds.contains(DBProfileAvatar.Columns.inboxId)) + .filter(DBProfileAvatar.Columns.url != nil) .fetchAll(db) - for profile in profiles { - guard let avatar = profile.avatar, - !seenURLs.contains(avatar), - Self.isValidAssetURL(avatar) else { continue } - seenURLs.insert(avatar) + for avatar in avatars { + guard let url = avatar.url, + !seenURLs.contains(url), + Self.isValidAssetURL(url) else { continue } + seenURLs.insert(url) assets.append(.profileAvatar( - url: avatar, - conversationId: profile.conversationId, - inboxId: profile.inboxId, - lastRenewed: profile.avatarLastRenewed + url: url, + conversationId: avatar.conversationId, + inboxId: avatar.inboxId, + lastRenewed: avatar.lastRenewed )) } @@ -86,25 +86,25 @@ public struct AssetRenewalURLCollector { var seenURLs: Set = [] // 1. My profile avatars that are stale (never renewed or renewed before threshold) - let profiles = try DBMemberProfile - .filter(allInboxIds.contains(DBMemberProfile.Columns.inboxId)) - .filter(DBMemberProfile.Columns.avatar != nil) + let avatars = try DBProfileAvatar + .filter(allInboxIds.contains(DBProfileAvatar.Columns.inboxId)) + .filter(DBProfileAvatar.Columns.url != nil) .filter( - DBMemberProfile.Columns.avatarLastRenewed == nil || - DBMemberProfile.Columns.avatarLastRenewed < staleThreshold + DBProfileAvatar.Columns.lastRenewed == nil || + DBProfileAvatar.Columns.lastRenewed < staleThreshold ) .fetchAll(db) - for profile in profiles { - guard let avatar = profile.avatar, - !seenURLs.contains(avatar), - Self.isValidAssetURL(avatar) else { continue } - seenURLs.insert(avatar) + for avatar in avatars { + guard let url = avatar.url, + !seenURLs.contains(url), + Self.isValidAssetURL(url) else { continue } + seenURLs.insert(url) assets.append(.profileAvatar( - url: avatar, - conversationId: profile.conversationId, - inboxId: profile.inboxId, - lastRenewed: profile.avatarLastRenewed + url: url, + conversationId: avatar.conversationId, + inboxId: avatar.inboxId, + lastRenewed: avatar.lastRenewed )) } diff --git a/ConvosCore/Sources/ConvosCore/Assets/ExpiredAssetRecoveryHandler.swift b/ConvosCore/Sources/ConvosCore/Assets/ExpiredAssetRecoveryHandler.swift index bb4d85d1e..9ae908fd4 100644 --- a/ConvosCore/Sources/ConvosCore/Assets/ExpiredAssetRecoveryHandler.swift +++ b/ConvosCore/Sources/ConvosCore/Assets/ExpiredAssetRecoveryHandler.swift @@ -18,18 +18,22 @@ public struct ExpiredAssetRecoveryHandler: Sendable { try await databaseWriter.write { db in switch asset { case let .profileAvatar(url, _, _, _): - // Clear ALL profiles with this avatar URL (same person may appear in multiple conversations) - let profiles = try DBMemberProfile - .filter(DBMemberProfile.Columns.avatar == url) + // Clear the URL + encryption material on all canonical avatar + // slots with this URL (same person may appear in multiple + // conversations). The row is kept so merge state is preserved. + var avatars = try DBProfileAvatar + .filter(DBProfileAvatar.Columns.url == url) .fetchAll(db) - for var profile in profiles { - profile = profile - .with(avatar: nil, salt: nil, nonce: nil, key: nil) - .with(avatarLastRenewed: nil) - try profile.save(db) + for i in avatars.indices { + avatars[i].url = nil + avatars[i].salt = nil + avatars[i].nonce = nil + avatars[i].encryptionKey = nil + avatars[i].lastRenewed = nil + try avatars[i].save(db) } - if !profiles.isEmpty { - Log.info("Cleared expired profile avatar URL from \(profiles.count) record(s)") + if !avatars.isEmpty { + Log.info("Cleared expired profile avatar URL from \(avatars.count) record(s)") } case let .groupImage(url, _, _): diff --git a/ConvosCore/Sources/ConvosCore/Contacts/ContactSyncCoordinator.swift b/ConvosCore/Sources/ConvosCore/Contacts/ContactSyncCoordinator.swift index 719ad6c05..d232f1c78 100644 --- a/ConvosCore/Sources/ConvosCore/Contacts/ContactSyncCoordinator.swift +++ b/ConvosCore/Sources/ConvosCore/Contacts/ContactSyncCoordinator.swift @@ -25,8 +25,9 @@ public protocol ContactSyncCoordinatorProtocol: Sendable { } /// Single entry point for the auto-add work described in the contact list PRD. -/// Wraps `ContactsWriter` and reads from the existing `conversation_members` -/// and `memberProfile` tables to seed each new contact with a profile snapshot. +/// Wraps `ContactsWriter` and reads from `conversation_members` plus the +/// canonical `profile` / `profileAvatar` tables to seed each new contact with a +/// profile snapshot. final class ContactSyncCoordinator: ContactSyncCoordinatorProtocol, @unchecked Sendable { private let databaseWriter: any DatabaseWriter private let databaseReader: any DatabaseReader @@ -117,11 +118,15 @@ final class ContactSyncCoordinator: ContactSyncCoordinatorProtocol, @unchecked S .filter(DBConversationMember.Columns.conversationId == conversationId) .fetchAll(db) - let profiles = try DBMemberProfile - .filter(DBMemberProfile.Columns.conversationId == conversationId) - .fetchAll(db) - let profilesByInboxId: [String: DBMemberProfile] = Dictionary( - uniqueKeysWithValues: profiles.map { ($0.inboxId, $0) } + // Canonical identity (name + agent kind + template metadata) lives in + // `DBProfile`; the per-conversation avatar lives in `DBProfileAvatar`. + // A member learned only from a streamed profile message has no legacy + // `DBMemberProfile` row, so reading canonical is required to seed the + // contact with a real name/avatar. + let memberInboxIds = members.map(\.inboxId) + let identities = try DBProfile.fetchAll(db, inboxIds: memberInboxIds) + let identitiesByInboxId: [String: DBProfile] = Dictionary( + uniqueKeysWithValues: identities.map { ($0.inboxId, $0) } ) var upsertedCount: Int = 0 @@ -129,25 +134,26 @@ final class ContactSyncCoordinator: ContactSyncCoordinatorProtocol, @unchecked S if member.inboxId == selfInboxId { continue } - let profile = profilesByInboxId[member.inboxId] + let identity = identitiesByInboxId[member.inboxId] + let avatar = try DBProfileAvatar.fetchOne(db, inboxId: member.inboxId, conversationId: conversationId) let snapshot = ContactProfileSnapshot( - displayName: profile?.name, - avatarURL: profile?.avatar, - avatarSalt: profile?.avatarSalt, - avatarNonce: profile?.avatarNonce, - avatarKey: profile?.avatarKey, + displayName: identity?.name, + avatarURL: avatar?.url, + avatarSalt: avatar?.salt, + avatarNonce: avatar?.nonce, + avatarKey: avatar?.encryptionKey, profileUpdatedAt: nil, // Derived from the stored memberKind. nil means no agent // signal (preserve existing); .agent / .verifiedConvos / // .verifiedUserOAuth map to the corresponding // AgentVerification. - agentVerification: profile?.memberKind?.agentVerification, + agentVerification: identity?.memberKind?.agentVerification, // Template identity for template-backed agents, persisted // so the contact can spawn a fresh instance after the // user leaves every conversation with a running instance. - agentTemplateId: profile?.agentTemplateId, - agentTemplatePublishedURL: profile?.agentTemplatePublishedURL, - agentTemplateEmoji: profile?.agentTemplateEmoji + agentTemplateId: identity?.agentTemplateId, + agentTemplatePublishedURL: identity?.agentTemplatePublishedURL, + agentTemplateEmoji: identity?.agentTemplateEmoji ) try ContactsWriter.upsertContactInTransaction( db: db, diff --git a/ConvosCore/Sources/ConvosCore/Crypto/AgentVerificationWriter.swift b/ConvosCore/Sources/ConvosCore/Crypto/AgentVerificationWriter.swift index 5d7fc4336..714e7ce9c 100644 --- a/ConvosCore/Sources/ConvosCore/Crypto/AgentVerificationWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Crypto/AgentVerificationWriter.swift @@ -4,8 +4,8 @@ import GRDB public enum AgentVerificationWriter { public static func reverifyUnverifiedAgents(in dbWriter: any DatabaseWriter) async throws { let unverifiedAgents = try await dbWriter.read { db in - try DBMemberProfile - .filter(DBMemberProfile.Columns.memberKind == DBMemberKind.agent.rawValue) + try DBProfile + .filter(DBProfile.Columns.memberKind == DBMemberKind.agent.rawValue) .fetchAll(db) } @@ -14,17 +14,26 @@ public enum AgentVerificationWriter { var updatedCount = 0 for profile in unverifiedAgents { - let verification = profile.hydrateProfile().verifyCachedAgentAttestation() + let hydrated = Profile.from(profile: profile, avatar: nil, inboxId: profile.inboxId, conversationId: "") + let verification = hydrated.verifyCachedAgentAttestation() guard verification.isVerified else { continue } let updatedKind = DBMemberKind.from(agentVerification: verification) try await dbWriter.write { db in - let updated = profile.with(memberKind: updatedKind) + var updated = profile + updated.memberKind = updatedKind try updated.save(db) - if updated.agentVerification.isConvosAgent, - let conversation = try DBConversation.fetchOne(db, id: updated.conversationId), - !conversation.hasHadVerifiedAgent { + guard verification.isConvosAgent else { return } + // `DBProfile` is per-inbox, so mark every conversation the agent + // is a member of (the legacy per-conversation row marked just one). + let conversationIds = try DBConversationMember + .filter(DBConversationMember.Columns.inboxId == updated.inboxId) + .fetchAll(db) + .map(\.conversationId) + for conversationId in conversationIds { + guard let conversation = try DBConversation.fetchOne(db, id: conversationId), + !conversation.hasHadVerifiedAgent else { continue } try conversation.with(hasHadVerifiedAgent: true).save(db) } } diff --git a/ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift b/ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift index 449fea1f8..608970494 100644 --- a/ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift +++ b/ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift @@ -410,7 +410,7 @@ extension MessagingService { if decodedMessage.isProfileMessage { let dbConversation = try await storeConversation(group, inboxId: currentInboxId) - await processProfileMessageInNSE(decodedMessage, conversationId: dbConversation.id, group: group) + await processProfileMessageInNSE(decodedMessage, conversationId: dbConversation.id, group: group, currentInboxId: currentInboxId) return .droppedMessage } @@ -655,8 +655,8 @@ extension MessagingService { private func isMemberAgent(inboxId: String, conversationId: String) async throws -> Bool { try await databaseReader.read { db in - let profile = try DBMemberProfile.fetchOne(db, conversationId: conversationId, inboxId: inboxId) - return profile?.isAgent ?? false + let profile = try DBProfile.fetchOne(db, inboxId: inboxId) + return profile?.memberKind?.isAgent ?? false } } @@ -859,21 +859,23 @@ extension MessagingService { private func processProfileMessageInNSE( _ message: DecodedMessage, conversationId: String, - group: XMTPiOS.Group + group: XMTPiOS.Group, + currentInboxId: String ) async { guard let contentType = try? message.encodedContent.type else { return } if contentType == ContentTypeProfileUpdate { - await processProfileUpdateInNSE(message, conversationId: conversationId, group: group) + await processProfileUpdateInNSE(message, conversationId: conversationId, group: group, currentInboxId: currentInboxId) } else if contentType == ContentTypeProfileSnapshot { - await processProfileSnapshotInNSE(message, conversationId: conversationId, group: group) + await processProfileSnapshotInNSE(message, conversationId: conversationId, group: group, currentInboxId: currentInboxId) } } private func processProfileUpdateInNSE( _ message: DecodedMessage, conversationId: String, - group: XMTPiOS.Group + group: XMTPiOS.Group, + currentInboxId: String ) async { guard let update = try? ProfileUpdateCodec().decode(content: message.encodedContent) else { return } let receivedAt = message.sentAt @@ -882,57 +884,22 @@ extension MessagingService { do { try await databaseWriter.write { db in - let member = DBMember(inboxId: senderInboxId) - try member.save(db) - - var profile = try DBMemberProfile.fetchOne( - db, - conversationId: conversationId, - inboxId: senderInboxId - ) ?? DBMemberProfile( + let metadata = update.profileMetadata + try ProfileInboundApplier.apply( + db: db, conversationId: conversationId, - inboxId: senderInboxId, - name: nil, - avatar: nil + event: ProfileInboundApplier.Incoming( + inboxId: senderInboxId, + source: .profileUpdate, + name: update.hasName ? update.name : nil, + avatar: .addressed(update.hasEncryptedImage ? update.encryptedImage : nil), + memberKind: update.memberKind.dbMemberKind, + metadata: metadata.isEmpty ? nil : metadata, + receivedAt: receivedAt + ), + selfInboxId: currentInboxId, + fallbackEncryptionKey: try DBConversation.fetchOne(db, id: conversationId)?.imageEncryptionKey ) - - // Never clear an existing name with a name-less/blank update - // (see DBMemberProfile.withInboundName). - profile = profile.withInboundName(update.hasName ? update.name : nil) - - if update.hasEncryptedImage, update.encryptedImage.isValid { - let encryptionKey: Data? = if let existingKey = profile.avatarKey { - existingKey - } else { - try DBConversation.fetchOne(db, id: conversationId)?.imageEncryptionKey - } - profile = profile.with( - avatar: update.encryptedImage.url, - salt: update.encryptedImage.salt, - nonce: update.encryptedImage.nonce, - key: encryptionKey - ) - } else { - profile = profile.with(avatar: nil, salt: nil, nonce: nil, key: nil) - } - - let priorMemberKind = profile.memberKind - profile = profile.with(memberKind: update.memberKind.dbMemberKind) - - if profile.isAgent { - let verification = profile.hydrateProfile().verifyCachedAgentAttestation() - if verification.isVerified { - profile = profile.with(memberKind: DBMemberKind.from(agentVerification: verification)) - } - } - - if let priorMemberKind, priorMemberKind.agentVerification.isVerified, - !profile.agentVerification.isVerified { - profile = profile.with(memberKind: priorMemberKind) - } - - try ContactsWriter.saveMemberProfileAndMirrorToContactInTransaction(db: db, profile: profile, receivedAt: receivedAt) - try Self.markConversationHasVerifiedAgentIfNeeded(profile: profile, conversationId: conversationId, db: db) } Log.debug("NSE: Processed ProfileUpdate from \(senderInboxId) in \(conversationId)") } catch { @@ -943,7 +910,8 @@ extension MessagingService { private func processProfileSnapshotInNSE( _ message: DecodedMessage, conversationId: String, - group: XMTPiOS.Group + group: XMTPiOS.Group, + currentInboxId: String ) async { guard let snapshot = try? ProfileSnapshotCodec().decode(content: message.encodedContent) else { return } // Use the message's authored timestamp, not wall-clock `Date()`. @@ -952,60 +920,26 @@ extension MessagingService { do { try await databaseWriter.write { db in - let encryptionKey = try DBConversation.fetchOne(db, id: conversationId)?.imageEncryptionKey - + let fallbackKey = try DBConversation.fetchOne(db, id: conversationId)?.imageEncryptionKey for memberProfile in snapshot.profiles { let inboxId = memberProfile.inboxIdString guard !inboxId.isEmpty else { continue } - - let member = DBMember(inboxId: inboxId) - try member.save(db) - - let existingProfile = try DBMemberProfile.fetchOne( - db, - conversationId: conversationId, - inboxId: inboxId - ) - - if existingProfile?.name != nil || existingProfile?.avatar != nil { - continue - } - - var profile = existingProfile ?? DBMemberProfile( + let metadata = memberProfile.profileMetadata + try ProfileInboundApplier.apply( + db: db, conversationId: conversationId, - inboxId: inboxId, - name: nil, - avatar: nil + event: ProfileInboundApplier.Incoming( + inboxId: inboxId, + source: .profileSnapshot, + name: memberProfile.hasName ? memberProfile.name : nil, + avatar: .fillIfPresent(memberProfile.hasEncryptedImage ? memberProfile.encryptedImage : nil), + memberKind: memberProfile.memberKind.dbMemberKind, + metadata: metadata.isEmpty ? nil : metadata, + receivedAt: receivedAt + ), + selfInboxId: currentInboxId, + fallbackEncryptionKey: fallbackKey ) - - profile = profile.with(name: memberProfile.hasName ? memberProfile.name : nil) - - if memberProfile.hasEncryptedImage, memberProfile.encryptedImage.isValid { - profile = profile.with( - avatar: memberProfile.encryptedImage.url, - salt: memberProfile.encryptedImage.salt, - nonce: memberProfile.encryptedImage.nonce, - key: existingProfile?.avatarKey ?? encryptionKey - ) - } - - let priorMemberKind = profile.memberKind - profile = profile.with(memberKind: memberProfile.memberKind.dbMemberKind) - - if profile.isAgent { - let verification = profile.hydrateProfile().verifyCachedAgentAttestation() - if verification.isVerified { - profile = profile.with(memberKind: DBMemberKind.from(agentVerification: verification)) - } - } - - if let priorMemberKind, priorMemberKind.agentVerification.isVerified, - !profile.agentVerification.isVerified { - profile = profile.with(memberKind: priorMemberKind) - } - - try ContactsWriter.saveMemberProfileAndMirrorToContactInTransaction(db: db, profile: profile, receivedAt: receivedAt) - try Self.markConversationHasVerifiedAgentIfNeeded(profile: profile, conversationId: conversationId, db: db) } } Log.debug("NSE: Processed ProfileSnapshot with \(snapshot.profiles.count) profiles in \(conversationId)") @@ -1014,17 +948,6 @@ extension MessagingService { } } - private static func markConversationHasVerifiedAgentIfNeeded( - profile: DBMemberProfile, - conversationId: String, - db: Database - ) throws { - guard profile.agentVerification.isConvosAgent, - let conversation = try DBConversation.fetchOne(db, id: conversationId), - !conversation.hasHadVerifiedAgent else { return } - try conversation.with(hasHadVerifiedAgent: true).save(db) - } - // MARK: - Conversation Storage /// Stores a conversation in the database along with its latest messages @@ -1114,30 +1037,31 @@ extension MessagingService { return "New Convo" } - let memberProfiles = try DBMemberProfile.fetchAll( - db, - conversationId: conversationId, - inboxIds: currentMemberInboxIds - ) + let profilesByInbox: [String: DBProfile] = try DBProfile + .filter(currentMemberInboxIds.contains(DBProfile.Columns.inboxId)) + .fetchAll(db) + .reduce(into: [:]) { $0[$1.inboxId] = $1 } - if memberProfiles.isEmpty { + if profilesByInbox.isEmpty { return "New Convo" } // Resolve each member like `Profile.formattedNamesString(memberNameOverride:)`: // the contact's display name (the user's global profile snapshot - // for this inbox) wins over the per-conversation profile name. + // for this inbox) wins over the canonical profile name. // Unnamed members are bucketed by agent vs. human so the rendered // title matches: anonymous agents read as "Agent" / "Agents", // anonymous humans as "Somebody" / "Somebodies". - let resolved: [(name: String?, isAgent: Bool)] = try memberProfiles.map { (profile: DBMemberProfile) -> (name: String?, isAgent: Bool) in - if let contactName = try ContactsRepository.contactNameInTransaction(db: db, inboxId: profile.inboxId) { - return (contactName, profile.isAgent) + let resolved: [(name: String?, isAgent: Bool)] = try currentMemberInboxIds.map { inboxId -> (name: String?, isAgent: Bool) in + let profile = profilesByInbox[inboxId] + let isAgent = profile?.memberKind?.isAgent ?? false + if let contactName = try ContactsRepository.contactNameInTransaction(db: db, inboxId: inboxId) { + return (contactName, isAgent) } - if let name = profile.name, !name.isEmpty { - return (name, profile.isAgent) + if let name = profile?.name, !name.isEmpty { + return (name, isAgent) } - return (nil, profile.isAgent) + return (nil, isAgent) } let namedProfiles: [String] = resolved.compactMap { $0.name }.sorted() let anonymousAgentCount: Int = resolved.filter { $0.name == nil && $0.isAgent }.count @@ -1184,11 +1108,11 @@ extension MessagingService { if let contactName = try ContactsRepository.contactNameInTransaction(db: db, inboxId: inboxId) { return contactName } - let profile = try DBMemberProfile.fetchOne(db, conversationId: conversationId, inboxId: inboxId) + let profile = try DBProfile.fetchOne(db, inboxId: inboxId) if let name = profile?.name, !name.isEmpty { return name } // Mirror `Profile.displayName`: known agents read as "Agent", // unknown / human profiles read as "Somebody". - return profile?.isAgent == true ? "Agent" : "Somebody" + return profile?.memberKind?.isAgent == true ? "Agent" : "Somebody" } // MARK: - Welcome Message Tracking diff --git a/ConvosCore/Sources/ConvosCore/Invites & Custom Metadata/ConversationCustomMetadata+Profiles.swift b/ConvosCore/Sources/ConvosCore/Invites & Custom Metadata/ConversationCustomMetadata+Profiles.swift index f9152ed9f..8aa163976 100644 --- a/ConvosCore/Sources/ConvosCore/Invites & Custom Metadata/ConversationCustomMetadata+Profiles.swift +++ b/ConvosCore/Sources/ConvosCore/Invites & Custom Metadata/ConversationCustomMetadata+Profiles.swift @@ -37,6 +37,61 @@ extension DBMemberProfile { } } +// MARK: - Canonical Profile + Snapshot MemberProfile + +extension DBProfile { + /// Projects the canonical per-inbox identity plus a conversation's avatar + /// slot into the `MemberProfile` used inside a `ProfileSnapshot` message. + /// Mirrors `DBMemberProfile.snapshotMemberProfile`: only encrypted image + /// refs go on the wire, so a plain avatar is represented by name with the + /// image omitted. Returns nil when the inbox id is not valid hex. + func snapshotMemberProfile(avatar: DBProfileAvatar?) -> MemberProfile? { + let encryptedImage: EncryptedProfileImageRef? = avatar?.snapshotEncryptedImageRef + guard var profile = MemberProfile( + inboxIdString: inboxId, + name: name, + encryptedImage: encryptedImage, + metadata: metadata + ) else { + return nil + } + if let memberKind { + profile.memberKind = memberKind.protoMemberKind + } + return profile + } +} + +extension DBMyProfile { + /// Projects the locally-authored self identity plus a conversation's avatar + /// slot into a snapshot `MemberProfile`. Self is excluded from `DBProfile`, + /// so the snapshot builder folds this in to advertise the sender's own + /// identity to joiners. Self is never an agent, so `memberKind` is omitted. + /// Returns nil when the inbox id is not valid hex. + func snapshotMemberProfile(avatar: DBProfileAvatar?) -> MemberProfile? { + let encryptedImage: EncryptedProfileImageRef? = avatar?.snapshotEncryptedImageRef + return MemberProfile( + inboxIdString: inboxId, + name: name, + encryptedImage: encryptedImage, + metadata: metadata + ) + } +} + +extension DBProfileAvatar { + /// The wire-format encrypted image ref for a snapshot, or nil when the slot + /// is a plain/absent avatar (only encrypted refs are put on the wire). + var snapshotEncryptedImageRef: EncryptedProfileImageRef? { + guard hasValidEncryptedAvatar, let url, let salt, let nonce else { return nil } + var ref = EncryptedProfileImageRef() + ref.url = url + ref.salt = salt + ref.nonce = nonce + return ref + } +} + // MARK: - MemberKind <-> DBMemberKind extension MemberKind { diff --git a/ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift b/ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift index 43f23d2c0..30f40f458 100644 --- a/ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift +++ b/ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift @@ -150,10 +150,6 @@ final class MessagingService: MessagingServiceProtocol, @unchecked Sendable { // MARK: My Profile - func myProfileWriter() -> any MyProfileWriterProtocol { - MyProfileWriter(sessionStateManager: sessionStateManager, databaseWriter: databaseWriter) - } - func myGlobalProfileWriter() -> any MyGlobalProfileWriterProtocol { MyGlobalProfileWriter(sessionStateManager: sessionStateManager, databaseWriter: databaseWriter) } @@ -162,8 +158,89 @@ final class MessagingService: MessagingServiceProtocol, @unchecked Sendable { MyGlobalProfileRepository(sessionStateManager: sessionStateManager, databaseReader: databaseReader) } + // MARK: Profiles (canonical Profile table, transition) + + private lazy var profileStore: any ProfileStoreProtocol = GRDBProfileStore( + databaseWriter: databaseWriter, + databaseReader: databaseReader + ) + + private lazy var selfProfileStore: any SelfProfileStoreProtocol = GRDBSelfProfileStore( + databaseWriter: databaseWriter, + databaseReader: databaseReader, + selfInboxIdProvider: { [sessionStateManager] in + (try? await sessionStateManager.waitForInboxReadyResult())?.client.inboxId + } + ) + + private lazy var profilePublishStore: any ProfilePublishStoreProtocol = GRDBProfilePublishStore( + databaseWriter: databaseWriter, + databaseReader: databaseReader + ) + + private lazy var sharedProfilesRepository: ProfilesRepository = ProfilesRepository( + profileStore: profileStore, + selfProfileStore: selfProfileStore, + publishStore: profilePublishStore, + databaseReader: databaseReader, + conversationLocalStateWriter: ConversationLocalStateWriter(databaseWriter: databaseWriter), + selfInboxIdProvider: { [sessionStateManager] in + (try? await sessionStateManager.waitForInboxReadyResult())?.client.inboxId + } + ) + + /// Canonical identity source. Inbound writes land here directly via + /// `ProfileInboundApplier`; reads are flipped onto it at the cutover. + func profilesRepository() -> ProfilesRepository { + sharedProfilesRepository + } + + /// One-time backfill of the canonical stores from any legacy `memberProfile` + /// rows that predate the direct inbound seam, then warms the repository cache + /// and attaches the durable publish session. Self-guards on inbox-ready, so + /// it is safe to call early. + func startProfileServices() async { + if let selfInboxId = (try? await sessionStateManager.waitForInboxReadyResult())?.client.inboxId { + do { + try await ProfileBackfill( + databaseReader: databaseReader, + profileStore: profileStore, + selfInboxId: selfInboxId + ).run() + } catch { + Log.error("Profile backfill failed: \(error)") + } + } + await sharedProfilesRepository.warmUp() + // Carry an upgraded user's existing global avatar into the publisher's + // source so it keeps propagating to conversations after the transition. + await sharedProfilesRepository.seedSelfAvatarSourceIfNeeded() + await sharedProfilesRepository.bind( + session: MessagingProfilePublishSession( + sessionStateManager: sessionStateManager + ) + ) + } + + func stopProfileServices() async { + await sharedProfilesRepository.unbind() + } + // MARK: New Conversation + /// Seeds a newly-ready conversation with the current user's global profile + /// through the shared repository's durable publisher. Handed to + /// `ConversationStateManager` so it does not depend on the repository. + private var profileConversationSeeder: @Sendable (String) async -> Void { + { [sharedProfilesRepository] conversationId in + do { + try await sharedProfilesRepository.publishMyProfileToConversation(conversationId) + } catch { + Log.warning("Failed to seed profile to conversation \(conversationId): \(error.localizedDescription)") + } + } + } + func conversationStateManager() -> any ConversationStateManagerProtocol { conversationStateManager(initialMemberInboxIds: []) } @@ -179,7 +256,8 @@ final class MessagingService: MessagingServiceProtocol, @unchecked Sendable { environment: environment, initialMemberInboxIds: initialMemberInboxIds, backgroundUploadManager: backgroundUploadManager, - coreActions: coreActions + coreActions: coreActions, + profileConversationSeeder: profileConversationSeeder ) } @@ -202,7 +280,8 @@ final class MessagingService: MessagingServiceProtocol, @unchecked Sendable { conversationId: conversationId, initialMemberInboxIds: initialMemberInboxIds, backgroundUploadManager: backgroundUploadManager, - coreActions: coreActions + coreActions: coreActions, + profileConversationSeeder: profileConversationSeeder ) } @@ -238,7 +317,14 @@ final class MessagingService: MessagingServiceProtocol, @unchecked Sendable { backgroundUploadManager: backgroundUploadManager, attachmentLocalStateWriter: AttachmentLocalStateWriter(databaseWriter: databaseWriter), contactSyncCoordinator: contactSyncCoordinator(), - coreActions: coreActions + coreActions: coreActions, + ensureProfilePublished: { [sharedProfilesRepository] in + do { + try await sharedProfilesRepository.publishMyProfileToConversation(conversationId) + } catch { + Log.warning("Failed to publish profile before send to \(conversationId): \(error.localizedDescription)") + } + } ) } @@ -316,7 +402,7 @@ final class MessagingService: MessagingServiceProtocol, @unchecked Sendable { /// instance is what lets a connections write and a timezone write queue /// behind each other instead of clobbering the per-sender metadata map. private lazy var sharedProfileMetadataWriter: ProfileMetadataWriter = ProfileMetadataWriter( - myProfileWriter: myProfileWriter(), + profilesRepository: { [sharedProfilesRepository] in sharedProfilesRepository }, databaseReader: databaseReader ) diff --git a/ConvosCore/Sources/ConvosCore/Messaging/MessagingServiceProtocol.swift b/ConvosCore/Sources/ConvosCore/Messaging/MessagingServiceProtocol.swift index b60d384ba..838b77526 100644 --- a/ConvosCore/Sources/ConvosCore/Messaging/MessagingServiceProtocol.swift +++ b/ConvosCore/Sources/ConvosCore/Messaging/MessagingServiceProtocol.swift @@ -45,10 +45,13 @@ public protocol MessagingServiceProtocol: AnyObject, Sendable, PostPairBroadcast func stopAndDelete() async func waitForDeletionComplete() async - func myProfileWriter() -> any MyProfileWriterProtocol func myGlobalProfileWriter() -> any MyGlobalProfileWriterProtocol func myGlobalProfileRepository() -> any MyGlobalProfileRepositoryProtocol + /// Canonical identity repository. Authors the current user's own profile + /// and fans it out to every conversation via the durable publisher. + func profilesRepository() -> ProfilesRepository + func conversationStateManager() -> any ConversationStateManagerProtocol func conversationStateManager(for conversationId: String) -> any ConversationStateManagerProtocol /// Same as `conversationStateManager()` but the state manager seeds diff --git a/ConvosCore/Sources/ConvosCore/Mocks/MockConversationLocalStateWriter.swift b/ConvosCore/Sources/ConvosCore/Mocks/MockConversationLocalStateWriter.swift index a4a7d4667..5e4de1e71 100644 --- a/ConvosCore/Sources/ConvosCore/Mocks/MockConversationLocalStateWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Mocks/MockConversationLocalStateWriter.swift @@ -8,6 +8,7 @@ public final class MockConversationLocalStateWriter: ConversationLocalStateWrite public var hidesInviteCardStates: [String: Bool] = [:] public var leftHostedInviteSessionStates: [String: Bool] = [:] public var hasSharedInviteStates: [String: Bool] = [:] + public var publishedProfileUpdatedAtStates: [String: Date?] = [:] public init() {} @@ -34,4 +35,8 @@ public final class MockConversationLocalStateWriter: ConversationLocalStateWrite public func setHasSharedInvite(_ hasSharedInvite: Bool, for conversationId: String) async throws { hasSharedInviteStates[conversationId] = hasSharedInvite } + + public func setPublishedProfileUpdatedAt(_ publishedProfileUpdatedAt: Date?, for conversationId: String) async throws { + publishedProfileUpdatedAtStates[conversationId] = publishedProfileUpdatedAt + } } diff --git a/ConvosCore/Sources/ConvosCore/Mocks/MockConversationStateManager.swift b/ConvosCore/Sources/ConvosCore/Mocks/MockConversationStateManager.swift index bcb0a948d..fd888b930 100644 --- a/ConvosCore/Sources/ConvosCore/Mocks/MockConversationStateManager.swift +++ b/ConvosCore/Sources/ConvosCore/Mocks/MockConversationStateManager.swift @@ -47,7 +47,6 @@ public final class MockConversationStateManager: ConversationStateManagerProtoco // MARK: - Dependencies - public let myProfileWriter: any MyProfileWriterProtocol public let draftConversationRepository: any DraftConversationRepositoryProtocol public let conversationConsentWriter: any ConversationConsentWriterProtocol public let conversationLocalStateWriter: any ConversationLocalStateWriterProtocol @@ -57,14 +56,12 @@ public final class MockConversationStateManager: ConversationStateManagerProtoco public init( conversationId: String? = nil, - myProfileWriter: (any MyProfileWriterProtocol)? = nil, draftConversationRepository: (any DraftConversationRepositoryProtocol)? = nil, conversationConsentWriter: (any ConversationConsentWriterProtocol)? = nil, conversationLocalStateWriter: (any ConversationLocalStateWriterProtocol)? = nil, conversationMetadataWriter: (any ConversationMetadataWriterProtocol)? = nil ) { self.conversationIdSubject = .init(conversationId ?? "mock-conversation-\(UUID().uuidString)") - self.myProfileWriter = myProfileWriter ?? MockMyProfileWriter() self.draftConversationRepository = draftConversationRepository ?? MockDraftConversationRepository() self.conversationConsentWriter = conversationConsentWriter ?? MockConversationConsentWriter() self.conversationLocalStateWriter = conversationLocalStateWriter ?? MockConversationLocalStateWriter() diff --git a/ConvosCore/Sources/ConvosCore/Mocks/MockMessagingService.swift b/ConvosCore/Sources/ConvosCore/Mocks/MockMessagingService.swift index c2d23b0ba..a930d2bcf 100644 --- a/ConvosCore/Sources/ConvosCore/Mocks/MockMessagingService.swift +++ b/ConvosCore/Sources/ConvosCore/Mocks/MockMessagingService.swift @@ -4,6 +4,7 @@ import UIKit import Combine import ConvosConnections import Foundation +import GRDB @preconcurrency import XMTPiOS /// Mock implementation of MessagingServiceProtocol for testing and previews @@ -14,7 +15,6 @@ public final class MockMessagingService: MessagingServiceProtocol, @unchecked Se // MARK: - Dependencies private let _sessionStateManager: any SessionStateManagerProtocol - private let _myProfileWriter: any MyProfileWriterProtocol private let _conversationStateManager: any ConversationStateManagerProtocol private let _conversationConsentWriter: any ConversationConsentWriterProtocol private let _conversationLocalStateWriter: any ConversationLocalStateWriterProtocol @@ -28,11 +28,16 @@ public final class MockMessagingService: MessagingServiceProtocol, @unchecked Se private let _myGlobalProfileWriter: any MyGlobalProfileWriterProtocol private let _myGlobalProfileRepository: any MyGlobalProfileRepositoryProtocol + /// Throwaway repository backed entirely by in-memory stores and an empty + /// database, matching how `MessagingService` constructs its shared + /// repository but with no persistence. `selfInboxIdProvider` returns nil, + /// so self-publish paths short-circuit instead of hitting the network. + private let _profilesRepository: ProfilesRepository + // MARK: - Initialization public init( sessionStateManager: (any SessionStateManagerProtocol)? = nil, - myProfileWriter: (any MyProfileWriterProtocol)? = nil, myGlobalProfileWriter: (any MyGlobalProfileWriterProtocol)? = nil, myGlobalProfileRepository: (any MyGlobalProfileRepositoryProtocol)? = nil, conversationStateManager: (any ConversationStateManagerProtocol)? = nil, @@ -47,7 +52,6 @@ public final class MockMessagingService: MessagingServiceProtocol, @unchecked Se replyWriter: (any ReplyMessageWriterProtocol)? = nil ) { self._sessionStateManager = sessionStateManager ?? MockSessionStateManager() - self._myProfileWriter = myProfileWriter ?? MockMyProfileWriter() self._conversationStateManager = conversationStateManager ?? MockConversationStateManager() self._conversationConsentWriter = conversationConsentWriter ?? MockConversationConsentWriter() self._conversationLocalStateWriter = conversationLocalStateWriter ?? MockConversationLocalStateWriter() @@ -60,6 +64,14 @@ public final class MockMessagingService: MessagingServiceProtocol, @unchecked Se self._replyWriter = replyWriter ?? MockReplyMessageWriter() self._myGlobalProfileWriter = myGlobalProfileWriter ?? MockMyGlobalProfileWriter() self._myGlobalProfileRepository = myGlobalProfileRepository ?? MockMyGlobalProfileRepository() + self._profilesRepository = ProfilesRepository( + profileStore: InMemoryProfileStore(), + selfProfileStore: InMemorySelfProfileStore(), + publishStore: InMemoryProfilePublishStore(), + databaseReader: MockDatabaseManager.previews.dbReader, + conversationLocalStateWriter: ConversationLocalStateWriter(databaseWriter: MockDatabaseManager.previews.dbWriter), + selfInboxIdProvider: { nil } + ) } // MARK: - MessagingServiceProtocol @@ -76,10 +88,6 @@ public final class MockMessagingService: MessagingServiceProtocol, @unchecked Se _sessionStateManager } - public func myProfileWriter() -> any MyProfileWriterProtocol { - _myProfileWriter - } - public func myGlobalProfileWriter() -> any MyGlobalProfileWriterProtocol { _myGlobalProfileWriter } @@ -88,6 +96,10 @@ public final class MockMessagingService: MessagingServiceProtocol, @unchecked Se _myGlobalProfileRepository } + public func profilesRepository() -> ProfilesRepository { + _profilesRepository + } + public func conversationStateManager() -> any ConversationStateManagerProtocol { _conversationStateManager } diff --git a/ConvosCore/Sources/ConvosCore/Mocks/MockMyProfileWriter.swift b/ConvosCore/Sources/ConvosCore/Mocks/MockMyProfileWriter.swift deleted file mode 100644 index dfbfea885..000000000 --- a/ConvosCore/Sources/ConvosCore/Mocks/MockMyProfileWriter.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -/// Mock implementation of MyProfileWriterProtocol for testing -public final class MockMyProfileWriter: MyProfileWriterProtocol, @unchecked Sendable { - public struct AvatarUpdate { - public let image: ImageType? - public let imageSourceContentDigest: String? - public let conversationId: String - } - - public var updatedDisplayNames: [(name: String, conversationId: String)] = [] - public var updatedAvatars: [AvatarUpdate] = [] - public var updatedMetadata: [(metadata: ProfileMetadata?, conversationId: String)] = [] - public var publishedMetadata: [(metadata: ProfileMetadata?, conversationId: String)] = [] - public var publishError: (any Error)? - - public init() {} - - public func update(displayName: String, conversationId: String) async throws { - updatedDisplayNames.append((name: displayName, conversationId: conversationId)) - } - - public func update(avatar: ImageType?, imageSourceContentDigest: String?, conversationId: String) async throws { - updatedAvatars.append(.init( - image: avatar, - imageSourceContentDigest: imageSourceContentDigest, - conversationId: conversationId - )) - } - - public func update(metadata: ProfileMetadata?, conversationId: String) async throws { - updatedMetadata.append((metadata: metadata, conversationId: conversationId)) - } - - public func updateAndPublish(metadata: ProfileMetadata?, conversationId: String) async throws { - publishedMetadata.append((metadata: metadata, conversationId: conversationId)) - if let publishError { - throw publishError - } - } - - public var syncedConversationIds: [String] = [] - - public func syncFromGlobalProfile(conversationId: String) async throws { - syncedConversationIds.append(conversationId) - } -} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/ProfileMessages/ProfileSnapshotBuilder.swift b/ConvosCore/Sources/ConvosCore/Profiles/ProfileMessages/ProfileSnapshotBuilder.swift index 2ab18ac94..0bb630f19 100644 --- a/ConvosCore/Sources/ConvosCore/Profiles/ProfileMessages/ProfileSnapshotBuilder.swift +++ b/ConvosCore/Sources/ConvosCore/Profiles/ProfileMessages/ProfileSnapshotBuilder.swift @@ -172,16 +172,34 @@ public enum ProfileSnapshotBuilder { _ = try await group.send(encodedContent: encoded) } - private static func fetchDBProfiles( + /// Internal (not private) so the self-union behavior can be exercised in a + /// unit test against a seeded database without a live XMTP group. + static func fetchDBProfiles( _ databaseReader: (any DatabaseReader)?, conversationId: String, memberInboxIds: [String] ) async throws -> [MemberProfile] { guard let databaseReader else { return [] } - let rows = try await databaseReader.read { db in - try DBMemberProfile.fetchAll(db, conversationId: conversationId, inboxIds: memberInboxIds) + return try await databaseReader.read { db in + var byInboxId: [String: MemberProfile] = [:] + for profile in try DBProfile.fetchAll(db, inboxIds: memberInboxIds) { + let avatar = try DBProfileAvatar.fetchOne(db, inboxId: profile.inboxId, conversationId: conversationId) + if let member = profile.snapshotMemberProfile(avatar: avatar) { + byInboxId[profile.inboxId] = member + } + } + // Self is excluded from `profile`; fold in the locally-authored + // `myProfile` rows so a sender always advertises its own identity + // to joiners, even before it has published a ProfileUpdate into the + // group or after that update has aged out of the message scan. + for myProfile in try DBMyProfile.fetchAll(db, inboxIds: memberInboxIds) where byInboxId[myProfile.inboxId] == nil { + let avatar = try DBProfileAvatar.fetchOne(db, inboxId: myProfile.inboxId, conversationId: conversationId) + if let member = myProfile.snapshotMemberProfile(avatar: avatar) { + byInboxId[myProfile.inboxId] = member + } + } + return Array(byInboxId.values) } - return rows.compactMap(\.snapshotMemberProfile) } private enum Constant { diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/MessagingProfilePublishSession.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/MessagingProfilePublishSession.swift new file mode 100644 index 000000000..a473370df --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/MessagingProfilePublishSession.swift @@ -0,0 +1,92 @@ +import Foundation +@preconcurrency import XMTPiOS + +/// Concrete `ProfilePublishSession` backed by the XMTP client and upload API. +/// Encrypts, uploads, and sends the self profile to a conversation, including the +/// best-effort second channel (writing the profile into group app-data) so +/// clients that read `ConversationProfile` rather than the `ProfileUpdate` +/// message still see the identity. +/// +/// This is boundary code (it uses XMTP types directly, like the writers). It is +/// exercised only once the publisher is fed at the cutover; there is no +/// meaningful unit test - it is verified via integration / manual runs. +struct MessagingProfilePublishSession: ProfilePublishSession { + private let sessionStateManager: any SessionStateManagerProtocol + + init(sessionStateManager: any SessionStateManagerProtocol) { + self.sessionStateManager = sessionStateManager + } + + func imageKey(conversationId: String) async throws -> Data? { + let inboxReady = try await sessionStateManager.waitForInboxReadyResult() + guard let conversation = try await inboxReady.client.conversation(with: conversationId), + case .group(let group) = conversation else { + return nil + } + return try await group.ensureImageEncryptionKey() + } + + func encrypt(_ plaintext: Data, groupKey: Data) throws -> EncryptedAvatarPayload { + let payload = try ImageEncryption.encrypt(imageData: plaintext, groupKey: groupKey) + return EncryptedAvatarPayload(ciphertext: payload.ciphertext, salt: payload.salt, nonce: payload.nonce) + } + + func upload(_ ciphertext: Data, filename: String) async throws -> String { + let inboxReady = try await sessionStateManager.waitForInboxReadyResult() + return try await inboxReady.apiClient.uploadAttachment( + data: ciphertext, + filename: filename, + contentType: "application/octet-stream", + acl: "public-read" + ) + } + + func sendProfileUpdate(name: String?, metadata: ProfileMetadata?, avatar: PublishedAvatar?, conversationId: String) async throws { + let inboxReady = try await sessionStateManager.waitForInboxReadyResult() + guard let conversation = try await inboxReady.client.conversation(with: conversationId), + case .group(let group) = conversation else { + throw ProfilePublishSessionError.conversationNotFound(conversationId: conversationId) + } + + let resolvedMetadata: ProfileMetadata? = (metadata?.isEmpty ?? true) ? nil : metadata + + var update = ProfileUpdate() + if let name { + update.name = name + } + if let avatar { + var ref = EncryptedProfileImageRef() + ref.url = avatar.url + ref.salt = avatar.salt + ref.nonce = avatar.nonce + update.encryptedImage = ref + } + if let resolvedMetadata { + update.metadata = resolvedMetadata.asProtoMap + } + let encoded = try ProfileUpdateCodec().encode(content: update) + _ = try await group.send(encodedContent: encoded) + + // Second channel: mirror into group app-data (best-effort). `updateProfile` + // merges, so a nil avatar preserves the existing app-data image rather + // than clearing it. + let memberProfile = DBMemberProfile( + conversationId: conversationId, + inboxId: inboxReady.client.inboxId, + name: name, + avatar: avatar?.url, + avatarSalt: avatar?.salt, + avatarNonce: avatar?.nonce, + avatarKey: avatar?.key + ).with(metadata: resolvedMetadata) + do { + try await group.updateProfile(memberProfile) + } catch { + Log.warning("ProfilePublishSession app-data updateProfile failed (best-effort): \(error.localizedDescription)") + } + } +} + +enum ProfilePublishSessionError: Error { + case conversationNotFound(conversationId: String) +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileBackfill.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileBackfill.swift new file mode 100644 index 000000000..6dad922a6 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileBackfill.swift @@ -0,0 +1,107 @@ +import Foundation +import GRDB + +/// One-time migration that seeds the canonical profile stores from the legacy +/// per-conversation `DBMemberProfile` rows. Runs at startup before +/// `ProfilesRepository.warmUp`, so the new tables are populated before any +/// reader fetches. +/// +/// Everything is written at the lowest source (`.contact`) and an epoch-floor +/// timestamp, so any real inbound event supersedes a backfilled value. Idempotent +/// and non-clobbering: re-running fills only blanks of whatever is already there, +/// and a value already set by a real `profileUpdate` is never downgraded. +/// +/// Only other members are migrated into `DBProfile`. The current user's own +/// identity lives in `myProfile` (written by the "My Info" editor), so self rows +/// are skipped for identity; their legacy avatars are still mirrored into +/// `DBProfileAvatar`. +/// +/// Runs once at startup. Inbound profile events no longer flow through here - +/// they write the canonical stores directly via `ProfileInboundApplier` - so +/// this only migrates rows that predate the direct seam. +struct ProfileBackfill { + private let databaseReader: any DatabaseReader + private let profileStore: any ProfileStoreProtocol + private let selfInboxId: String + + /// Floor timestamp for backfilled rows; any real event (later `sentAt`) + /// supersedes them. + private let floor: Date = .init(timeIntervalSince1970: 0) + + init( + databaseReader: any DatabaseReader, + profileStore: any ProfileStoreProtocol, + selfInboxId: String + ) { + self.databaseReader = databaseReader + self.profileStore = profileStore + self.selfInboxId = selfInboxId + } + + func run() async throws { + let rows = try await databaseReader.read { db in + try DBMemberProfile.fetchAll(db) + } + try await mirror(rows) + } + + /// Mirrors the given legacy rows into the canonical stores. Idempotent: a + /// write only happens when the merged value differs from what's stored, so a + /// re-run does not churn writes for unchanged rows. + func mirror(_ rows: [DBMemberProfile]) async throws { + guard !rows.isEmpty else { return } + + // Collapse a person's multiple conversation rows into one identity. Self + // identity lives in `myProfile`, so self rows are skipped here; their + // avatars are still backfilled below. + var identityByInbox: [String: DBProfile] = [:] + for row in rows { + if row.inboxId != selfInboxId { + identityByInbox[row.inboxId] = ProfileMerge.mergeIdentity( + existing: identityByInbox[row.inboxId], + inboxId: row.inboxId, + incoming: IncomingIdentity(name: row.name, memberKind: row.memberKind, metadata: row.metadata), + source: .contact, + sentAt: floor + ) + } + try await backfillAvatar(row) + } + + try await backfillIdentities(identityByInbox) + } + + private func backfillAvatar(_ row: DBMemberProfile) async throws { + guard row.hasValidEncryptedAvatar, let url = row.avatar else { return } + let existing = try await profileStore.avatar(inboxId: row.inboxId, conversationId: row.conversationId) + let merged = ProfileMerge.mergeAvatar( + existing: existing, + inboxId: row.inboxId, + conversationId: row.conversationId, + incoming: .set(url: url, salt: row.avatarSalt, nonce: row.avatarNonce, key: row.avatarKey), + source: .contact, + sentAt: floor + ) + if let merged, merged != existing { + try await profileStore.saveAvatar(merged) + } + } + + private func backfillIdentities(_ identityByInbox: [String: DBProfile]) async throws { + for (inboxId, accumulated) in identityByInbox { + // Merge against whatever is already stored at `.contact`/floor, so a + // value already set by a real event is preserved, not overwritten. + let existing = try await profileStore.identity(inboxId: inboxId) + let merged = ProfileMerge.mergeIdentity( + existing: existing, + inboxId: inboxId, + incoming: IncomingIdentity(name: accumulated.name, memberKind: accumulated.memberKind, metadata: accumulated.metadata), + source: .contact, + sentAt: floor + ) + if merged != existing { + try await profileStore.saveIdentity(merged) + } + } + } +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileDomainEvent.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileDomainEvent.swift new file mode 100644 index 000000000..ed858ab8a --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileDomainEvent.swift @@ -0,0 +1,42 @@ +import Foundation + +/// The identity fields carried by one inbound profile event, before merge. +struct IncomingIdentity: Hashable, Sendable { + var name: String? + var memberKind: DBMemberKind? + var metadata: ProfileMetadata? + + init(name: String? = nil, memberKind: DBMemberKind? = nil, metadata: ProfileMetadata? = nil) { + self.name = name + self.memberKind = memberKind + self.metadata = metadata + } +} + +/// How an inbound event addresses a member's avatar for a conversation. The +/// distinction is load-bearing for the merge: only `set` and `explicitClear` +/// change the slot; `silent` leaves it untouched (e.g. a name-only update). +enum IncomingAvatar: Hashable, Sendable { + /// A new avatar for the slot. `salt`/`nonce`/`key` are present for encrypted + /// avatars and nil for a plain URL. + case set(url: String, salt: Data?, nonce: Data?, key: Data?) + /// An explicit "avatar removed" intent (tombstone). Distinct from `silent`. + case explicitClear + /// The event does not address the avatar at all. + case silent +} + +/// A single inbound identity update for one `(inboxId, conversationId)`, tagged +/// with the source that produced it. `ProfilesRepository.apply` merges it into +/// the canonical stores by precedence and recency. +/// +/// Constructed at the sync seam (see the inbound-seam PR) from decoded +/// `ProfileUpdate` / `ProfileSnapshot` / app-data; not used by the app yet. +struct ProfileDomainEvent: Sendable { + let inboxId: String + let conversationId: String + let source: ProfileSource + let identity: IncomingIdentity + let avatar: IncomingAvatar + let sentAt: Date +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileInboundApplier.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileInboundApplier.swift new file mode 100644 index 000000000..33449727c --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileInboundApplier.swift @@ -0,0 +1,177 @@ +import Foundation +import GRDB + +/// Writes one inbound profile event into the canonical `profile` / `profileAvatar` +/// tables, synchronously and inside an existing write transaction. This is the +/// cutover replacement for the legacy `DBMemberProfile` writes; every inbound +/// site (foreground stream, NSE extension, history catch-up) calls it inside its +/// own `databaseWriter.write { db in ... }` so the identity write shares the +/// message's transaction. +/// +/// The merge itself reuses `ProfileMerge` (precedence + recency + tri-state +/// avatar), so behaviour matches `ProfilesRepository.apply`. Agent attestation is +/// preserved exactly: a transient, never-saved `DBMemberProfile` probe is +/// hydrated and verified as before, and the resolved `memberKind` flows into the +/// canonical identity and the `hasHadVerifiedAgent` marking. The current user's +/// own inbox is skipped - self identity is authored locally via `publishMyProfile` +/// / `selfProfile`, not received. +enum ProfileInboundApplier { + /// How an inbound message addresses the avatar slot. A `ProfileUpdate` + /// addresses it (absent image means "cleared"); a `ProfileSnapshot` or a + /// history replay only fills it (absent image leaves the slot untouched). + enum AvatarDisposition { + case addressed(EncryptedProfileImageRef?) + case fillIfPresent(EncryptedProfileImageRef?) + } + + /// One inbound identity + avatar event for a member, before merge. Bundled so + /// the seam's call sites (and `apply`) stay under the parameter-count limit. + struct Incoming { + let inboxId: String + let source: ProfileSource + let name: String? + let avatar: AvatarDisposition + let memberKind: DBMemberKind? + let metadata: ProfileMetadata? + let receivedAt: Date + } + + static func apply( + db: Database, + conversationId: String, + event: Incoming, + selfInboxId: String?, + fallbackEncryptionKey: Data? + ) throws { + let inboxId = event.inboxId + guard !inboxId.isEmpty else { return } + try DBMember(inboxId: inboxId).save(db) + + let existingProfile = try DBProfile.fetchOne(db, inboxId: inboxId) + let resolvedKind = resolvedMemberKind( + incomingKind: event.memberKind, + name: event.name, + metadata: event.metadata, + priorKind: existingProfile?.memberKind, + inboxId: inboxId, + conversationId: conversationId + ) + try markConversationHasVerifiedAgentIfNeeded(memberKind: resolvedKind, conversationId: conversationId, db: db) + + // Self identity is authored locally; skip inbound self echoes for the + // canonical profile/avatar (matches ProfilesRepository.apply). + if let selfInboxId, inboxId == selfInboxId { return } + + let mergedProfile = ProfileMerge.mergeIdentity( + existing: existingProfile, + inboxId: inboxId, + incoming: IncomingIdentity(name: event.name, memberKind: resolvedKind, metadata: event.metadata), + source: event.source, + sentAt: event.receivedAt + ) + if mergedProfile != existingProfile { + try mergedProfile.save(db) + } + + let existingAvatar = try DBProfileAvatar.fetchOne(db, inboxId: inboxId, conversationId: conversationId) + let incomingAvatar = avatarEvent(event.avatar, existingKey: existingAvatar?.encryptionKey, fallbackKey: fallbackEncryptionKey) + let mergedAvatar = ProfileMerge.mergeAvatar( + existing: existingAvatar, + inboxId: inboxId, + conversationId: conversationId, + incoming: incomingAvatar, + source: event.source, + sentAt: event.receivedAt + ) + if let mergedAvatar, mergedAvatar != existingAvatar { + try mergedAvatar.save(db) + } + + // Keep the contact list fresh: mirror the resolved identity/avatar onto + // this member's `DBContact` when one exists. No-ops for non-contacts, so + // a later `ProfileUpdate` refreshes an existing contact's name/avatar + // instead of freezing it at first-seen. + let effectiveAvatar = mergedAvatar ?? existingAvatar + try ContactsWriter.mirrorMemberProfileToContactInTransaction( + db: db, + inboxId: inboxId, + name: mergedProfile.name, + avatarURL: effectiveAvatar?.url, + avatarSalt: effectiveAvatar?.salt, + avatarNonce: effectiveAvatar?.nonce, + avatarKey: effectiveAvatar?.encryptionKey, + receivedAt: event.receivedAt, + agentVerification: mergedProfile.memberKind?.agentVerification, + agentTemplateId: mergedProfile.agentTemplateId, + agentTemplatePublishedURL: mergedProfile.agentTemplatePublishedURL, + agentTemplateEmoji: mergedProfile.agentTemplateEmoji + ) + } + + private static func avatarEvent(_ disposition: AvatarDisposition, existingKey: Data?, fallbackKey: Data?) -> IncomingAvatar { + switch disposition { + case let .addressed(image): + // Deferred protocol work: do not clear on an absent image yet. + // + // The wire format cannot distinguish "I deliberately cleared my + // avatar" from "I updated another field and omitted the image", and + // a malformed ref is also indistinguishable from an intentional + // clear. Until a dedicated deliberate-clear signal exists in the + // ProfileUpdate/Snapshot message types, an absent or malformed image + // is treated as `.silent` (leave the slot untouched), never a clear. + // + // Rationale: the app UI cannot clear an avatar today, so there is no + // legitimate clear intent on the wire. Clearing on an omitted image + // would wrongly wipe an avatar on a name-only update from any client + // that does not re-send the image - a regression we must not + // introduce. When the protocol gains a deliberate-clear flag, restore + // `.explicitClear` for the true-clear case here (and only here). + guard let image, image.isValid else { return .silent } + return .set(url: image.url, salt: image.salt, nonce: image.nonce, key: existingKey ?? fallbackKey) + case let .fillIfPresent(image): + guard let image, image.isValid else { return .silent } + return .set(url: image.url, salt: image.salt, nonce: image.nonce, key: existingKey ?? fallbackKey) + } + } + + /// Resolves the stored `memberKind`, preserving the legacy agent-attestation + /// behaviour: verify a cached attestation for agents (which can upgrade the + /// kind), and never downgrade a previously verified kind. Uses a transient + /// `DBMemberProfile` (never saved) so the verification path is identical to + /// the pre-cutover writer. + private static func resolvedMemberKind( + incomingKind: DBMemberKind?, + name: String?, + metadata: ProfileMetadata?, + priorKind: DBMemberKind?, + inboxId: String, + conversationId: String + ) -> DBMemberKind? { + var probe = DBMemberProfile(conversationId: conversationId, inboxId: inboxId, name: name, avatar: nil) + probe = probe.with(memberKind: incomingKind) + if let metadata { + probe = probe.with(metadata: metadata) + } + if probe.isAgent { + let verification = probe.hydrateProfile().verifyCachedAgentAttestation() + if verification.isVerified { + probe = probe.with(memberKind: DBMemberKind.from(agentVerification: verification)) + } + } + if let priorKind, priorKind.agentVerification.isVerified, !probe.agentVerification.isVerified { + probe = probe.with(memberKind: priorKind) + } + return probe.memberKind + } + + private static func markConversationHasVerifiedAgentIfNeeded( + memberKind: DBMemberKind?, + conversationId: String, + db: Database + ) throws { + guard memberKind?.agentVerification.isConvosAgent == true, + let conversation = try DBConversation.fetchOne(db, id: conversationId), + !conversation.hasHadVerifiedAgent else { return } + try conversation.with(hasHadVerifiedAgent: true).save(db) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileMerge.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileMerge.swift new file mode 100644 index 000000000..fd485e35d --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileMerge.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Pure merge rules for inbound identity and avatar updates. No I/O; the +/// repository calls these and persists the result. Combines two axes: +/// +/// - Precedence: a higher `ProfileSource` always wins; within the same source, +/// the newer `sentAt` wins; a lower source only fills blanks. +/// - Guards: an empty/absent name never clears a populated one, a verified +/// assistant kind is never downgraded to generic `agent`, and the avatar is +/// tri-state (only `set`/`explicitClear` change a slot; `silent` leaves it). +enum ProfileMerge { + /// Trimmed, non-empty name, or nil. A name that is nil/blank/whitespace is + /// treated as "no name provided". + static func nonBlank(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + static func isVerifiedAssistant(_ kind: DBMemberKind?) -> Bool { + kind == .verifiedConvos || kind == .verifiedUserOAuth + } + + /// Never downgrade a verified assistant to generic `agent` (or nil); + /// otherwise prefer the incoming kind, falling back to the existing one. + static func preserveVerifiedKind(_ existing: DBMemberKind?, _ incoming: DBMemberKind?) -> DBMemberKind? { + if isVerifiedAssistant(existing), !isVerifiedAssistant(incoming) { + return existing + } + return incoming ?? existing + } + + static func mergeIdentity( + existing: DBProfile?, + inboxId: String, + incoming: IncomingIdentity, + source: ProfileSource, + sentAt: Date + ) -> DBProfile { + guard let existing else { + return DBProfile( + inboxId: inboxId, + name: nonBlank(incoming.name), + memberKind: incoming.memberKind, + metadata: incoming.metadata, + profileSource: source, + updatedAt: sentAt + ) + } + + var result = existing + if winsOver(existingSource: existing.profileSource, existingUpdatedAt: existing.updatedAt, source: source, sentAt: sentAt) { + result.name = nonBlank(incoming.name) ?? existing.name + result.memberKind = preserveVerifiedKind(existing.memberKind, incoming.memberKind) + result.metadata = incoming.metadata ?? existing.metadata + result.profileSource = source + result.updatedAt = sentAt + } else { + // Lower precedence or older: fill blanks only; keep provenance and + // never let a low-priority event change an existing kind. + result.name = existing.name ?? nonBlank(incoming.name) + result.memberKind = existing.memberKind ?? incoming.memberKind + result.metadata = existing.metadata ?? incoming.metadata + } + return result + } + + static func mergeAvatar( + existing: DBProfileAvatar?, + inboxId: String, + conversationId: String, + incoming: IncomingAvatar, + source: ProfileSource, + sentAt: Date + ) -> DBProfileAvatar? { + let setFields: AvatarFields? + switch incoming { + case .silent: + return existing + case .explicitClear: + setFields = nil + case let .set(url, salt, nonce, key): + // Every profile avatar must be group-encrypted. A set missing any + // crypto field is not a valid encrypted avatar; ignore it rather + // than store an unencrypted slot or downgrade an existing encrypted + // one to plaintext. + guard let salt, let nonce, let key else { + return existing + } + setFields = AvatarFields(url: url, salt: salt, nonce: nonce, key: key) + } + + let wins = existing.map { + winsOver(existingSource: $0.profileSource, existingUpdatedAt: $0.updatedAt, source: source, sentAt: sentAt) + } ?? true + + if wins { + return DBProfileAvatar( + inboxId: inboxId, + conversationId: conversationId, + url: setFields?.url, + salt: setFields?.salt, + nonce: setFields?.nonce, + encryptionKey: setFields?.key, + profileSource: source, + updatedAt: sentAt + ) + } + + // Lower precedence or older: leave the slot untouched. Unlike a name + // (where a nil value means "not yet known" and is gap-filled), an avatar + // slot exists only because of a prior set or explicit clear, so a + // url == nil slot is a tombstone, not an unknown - a lower/older event + // must not resurrect it. A genuinely empty inbox has no slot at all + // (existing == nil), which wins above and is created from any source. + return existing + } + + private static func winsOver( + existingSource: ProfileSource, + existingUpdatedAt: Date, + source: ProfileSource, + sentAt: Date + ) -> Bool { + source > existingSource || (source == existingSource && sentAt >= existingUpdatedAt) + } + + /// The fields an avatar `set` carries, extracted so `mergeAvatar` avoids a + /// four-member tuple. + private struct AvatarFields { + let url: String + let salt: Data? + let nonce: Data? + let key: Data? + } +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilePublishSession.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilePublishSession.swift new file mode 100644 index 000000000..6bd662c52 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilePublishSession.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Result of encrypting plaintext avatar bytes; cached on a publish job so a +/// restart re-uploads identical ciphertext without re-encrypting. +struct EncryptedAvatarPayload: Sendable { + let ciphertext: Data + let salt: Data + let nonce: Data +} + +/// A fully-resolved avatar reference, ready to advertise in a ProfileUpdate and +/// store in the local avatar slot. +struct PublishedAvatar: Sendable { + let url: String + let salt: Data + let nonce: Data + let key: Data +} + +/// The XMTP- and upload-facing seam the publisher delegates to, keeping +/// `ProfilePublisher` (and ConvosCore) free of XMTPiOS. The messaging layer +/// provides the concrete implementation when the publisher is wired up. +protocol ProfilePublishSession: Sendable { + /// The conversation's image-encryption (group) key, or nil if the + /// conversation no longer exists - in which case its publish job is dropped. + func imageKey(conversationId: String) async throws -> Data? + + /// Encrypts plaintext avatar bytes under the conversation's group key. + func encrypt(_ plaintext: Data, groupKey: Data) throws -> EncryptedAvatarPayload + + /// Uploads ciphertext, returning the URL it can be fetched from. + func upload(_ ciphertext: Data, filename: String) async throws -> String + + /// Sends a ProfileUpdate carrying the name, metadata, and (optional) avatar + /// to one conversation. + func sendProfileUpdate(name: String?, metadata: ProfileMetadata?, avatar: PublishedAvatar?, conversationId: String) async throws +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilePublisher.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilePublisher.swift new file mode 100644 index 000000000..dab4268d8 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilePublisher.swift @@ -0,0 +1,286 @@ +import Foundation + +/// Capped exponential backoff with jitter for failed publish jobs. +struct PublishBackoff: Sendable { + let base: TimeInterval + let cap: TimeInterval + let jitterFraction: Double + + static let `default`: PublishBackoff = .init(base: 1, cap: 300, jitterFraction: 0.25) + + func delay(forAttempt attempt: Int64) -> TimeInterval { + let shift = Double(max(0, attempt - 1)) + let exponential = min(cap, base * pow(2, shift)) + let jitter = exponential * jitterFraction * Double.random(in: -1...1) + return max(0, exponential + jitter) + } +} + +/// Drains the durable profile publish queue: for each conversation, encrypts the +/// current source avatar once (caching the ciphertext so a restart re-uploads +/// identical bytes), uploads it, sends a ProfileUpdate, and writes the local +/// avatar slot. Failures reschedule only that job with capped backoff so the +/// loop is never head-of-line blocked. XMTP and upload specifics live behind the +/// injected `ProfilePublishSession`. +/// +/// `ProfilesRepository` owns the instance and attaches a session via +/// `bind(session:)` at startup, which resumes any leftover jobs. Within a +/// process, drains are serialized. +actor ProfilePublisher { + private let publishStore: any ProfilePublishStoreProtocol + private let profileStore: any ProfileStoreProtocol + private let selfProfileStore: any SelfProfileStoreProtocol + private let conversationLocalStateWriter: any ConversationLocalStateWriterProtocol + private let selfInboxIdProvider: @Sendable () async -> String? + private let now: @Sendable () -> Date + private let backoff: PublishBackoff + + private var session: (any ProfilePublishSession)? + private var draining: Bool = false + private var cachedSelfInboxId: String? + + init( + publishStore: any ProfilePublishStoreProtocol, + profileStore: any ProfileStoreProtocol, + selfProfileStore: any SelfProfileStoreProtocol, + conversationLocalStateWriter: any ConversationLocalStateWriterProtocol, + selfInboxIdProvider: @escaping @Sendable () async -> String?, + now: @escaping @Sendable () -> Date = { Date() }, + backoff: PublishBackoff = .default + ) { + self.publishStore = publishStore + self.profileStore = profileStore + self.selfProfileStore = selfProfileStore + self.conversationLocalStateWriter = conversationLocalStateWriter + self.selfInboxIdProvider = selfInboxIdProvider + self.now = now + self.backoff = backoff + } + + private func resolveSelfInboxId() async -> String? { + if let cachedSelfInboxId { return cachedSelfInboxId } + let resolved = await selfInboxIdProvider() + cachedSelfInboxId = resolved + return resolved + } + + func attach(session: any ProfilePublishSession) async { + self.session = session + await drainReadyJobs() + } + + func detach() { + session = nil + } + + /// Records a new source avatar so subsequent per-conversation publishes + /// re-encrypt it to each conversation's group key. Does not enqueue or fan + /// out - propagation is lazy and per-conversation via `publishConversation`. + func updateAvatarSource(_ avatarBytes: Data) async throws { + guard let selfInboxId = await resolveSelfInboxId() else { return } + // Atomic read-increment-write: two concurrent avatar edits must not read + // the same version and both write it, or the stale-source supersede check + // in processAvatarJob would fail to detect the earlier batch as stale. + _ = try await publishStore.bumpAvatarSource(inboxId: selfInboxId, plaintext: avatarBytes, updatedAt: now()) + } + + /// Seeds the avatar source from pre-existing global avatar bytes, but only + /// when no source exists yet. Lets an upgraded user's saved avatar propagate + /// via the lazy per-conversation publish without clobbering a source the user + /// set after upgrading. + func seedAvatarSourceIfAbsent(_ avatarBytes: Data) async throws { + guard let selfInboxId = await resolveSelfInboxId() else { return } + guard try await publishStore.source(inboxId: selfInboxId) == nil else { return } + try await publishStore.setSource( + DBProfileAvatarSource(inboxId: selfInboxId, plaintext: avatarBytes, version: 1, updatedAt: now()) + ) + } + + /// Seeds a single conversation with the current profile (e.g. a freshly + /// created or joined group), then drains. + func publishConversation(_ conversationId: String) async throws { + guard let selfInboxId = await resolveSelfInboxId() else { return } + let source = try await publishStore.source(inboxId: selfInboxId) + try await enqueueJob(conversationId: conversationId, sourceVersion: source?.version, hasAvatar: source != nil) + await drainReadyJobs() + } + + func drainReadyJobs() async { + guard !draining, let session else { return } + draining = true + defer { draining = false } + guard let selfInboxId = await resolveSelfInboxId() else { return } + // Return jobs a previous drain left mid-flight to the ready pool. + try? await publishStore.reclaimStalledJobs() + while let job = try? await publishStore.nextReadyJob(now: now()) { + guard let claimed = await claim(job) else { break } + await process(claimed, session: session, selfInboxId: selfInboxId) + } + } + + /// Marks a ready job in-flight (`uploading`) before processing it. Because + /// `nextReadyJob` only returns `pending` jobs, this guarantees a job whose + /// delete or reschedule fails can't be handed straight back to the loop and + /// spin it. Returns nil (stop draining) if the claim write itself fails, so + /// a persistent write error can't hot-loop. A job left `uploading` by an + /// interrupted drain is reclaimed at the next drain. + private func claim(_ job: DBProfilePublishJob) async -> DBProfilePublishJob? { + var claimed = job + claimed.state = .uploading + claimed.updatedAt = now() + do { + try await publishStore.update(claimed) + return claimed + } catch { + Log.error("ProfilePublisher: failed to claim job \(job.id): \(error)") + return nil + } + } + + // MARK: - Enqueue + + private func enqueueJob(conversationId: String, sourceVersion: Int64?, hasAvatar: Bool) async throws { + let timestamp = now() + // Assign seq, insert, and supersede this conversation's older jobs in one + // atomic write so concurrent enqueues can't collide on seq. + try await publishStore.enqueueNext { seq in + DBProfilePublishJob( + id: UUID().uuidString, + seq: seq, + conversationId: conversationId, + sourceVersion: sourceVersion, + hasAvatar: hasAvatar, + nextAttemptAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp + ) + } + } + + // MARK: - Process + + private func process(_ job: DBProfilePublishJob, session: any ProfilePublishSession, selfInboxId: String) async { + do { + if job.hasAvatar { + try await processAvatarJob(job, session: session, selfInboxId: selfInboxId) + } else { + try await processNameOnlyJob(job, session: session, selfInboxId: selfInboxId) + } + try? await publishStore.deleteJob(id: job.id) + } catch is PublishDropError { + try? await publishStore.deleteJob(id: job.id) + } catch { + await reschedule(job, error: error) + } + } + + private func processAvatarJob(_ job: DBProfilePublishJob, session: any ProfilePublishSession, selfInboxId: String) async throws { + guard let version = job.sourceVersion, + let source = try await publishStore.source(inboxId: selfInboxId), + source.version == version else { + throw PublishDropError.staleSource + } + guard let groupKey = try await session.imageKey(conversationId: job.conversationId) else { + throw PublishDropError.conversationGone + } + + var current = job + if current.ciphertext == nil || current.groupKey != groupKey { + let payload = try session.encrypt(source.plaintext, groupKey: groupKey) + current.ciphertext = payload.ciphertext + current.salt = payload.salt + current.nonce = payload.nonce + current.groupKey = groupKey + current.filename = "ep-\(UUID().uuidString).enc" + current.uploadedURL = nil + current.updatedAt = now() + try await publishStore.update(current) + } + + let url: String + if let uploaded = current.uploadedURL { + url = uploaded + } else { + guard let ciphertext = current.ciphertext, let filename = current.filename else { + throw PublishDropError.staleSource + } + url = try await session.upload(ciphertext, filename: filename) + current.uploadedURL = url + current.updatedAt = now() + try await publishStore.update(current) + } + + guard let salt = current.salt, let nonce = current.nonce, let key = current.groupKey else { + throw PublishDropError.staleSource + } + let published = PublishedAvatar(url: url, salt: salt, nonce: nonce, key: key) + let selfProfile = try await selfProfileStore.load() + try await session.sendProfileUpdate(name: selfProfile?.name, metadata: selfProfile?.metadata, avatar: published, conversationId: job.conversationId) + let slot = DBProfileAvatar( + inboxId: selfInboxId, + conversationId: job.conversationId, + url: url, + salt: salt, + nonce: nonce, + encryptionKey: key, + profileSource: .profileUpdate, + updatedAt: now() + ) + try await profileStore.saveAvatar(slot) + await stampPublished(selfProfile, conversationId: job.conversationId) + } + + private func processNameOnlyJob(_ job: DBProfilePublishJob, session: any ProfilePublishSession, selfInboxId: String) async throws { + let existing = try await profileStore.avatar(inboxId: selfInboxId, conversationId: job.conversationId) + let published = publishedAvatar(from: existing) + let selfProfile = try await selfProfileStore.load() + try await session.sendProfileUpdate(name: selfProfile?.name, metadata: selfProfile?.metadata, avatar: published, conversationId: job.conversationId) + await stampPublished(selfProfile, conversationId: job.conversationId) + } + + /// Records that the self profile as of `selfProfile.updatedAt` has actually + /// been delivered to this conversation, so the change-aware lazy sync stops + /// re-publishing until the next edit. Stamped only after a successful send - + /// never optimistically - so a failed or dropped publish leaves the + /// conversation marked stale and eligible for retry. Best-effort: a stamp + /// failure just means a harmless duplicate publish next time. + private func stampPublished(_ selfProfile: DBMyProfile?, conversationId: String) async { + guard let updatedAt = selfProfile?.updatedAt else { return } + try? await conversationLocalStateWriter.setPublishedProfileUpdatedAt(updatedAt, for: conversationId) + } + + private func publishedAvatar(from slot: DBProfileAvatar?) -> PublishedAvatar? { + guard let slot, let url = slot.url, let salt = slot.salt, let nonce = slot.nonce, let key = slot.encryptionKey else { + return nil + } + return PublishedAvatar(url: url, salt: salt, nonce: nonce, key: key) + } + + private func reschedule(_ job: DBProfilePublishJob, error: any Error) async { + // Re-fetch so the reschedule preserves any ciphertext/uploaded URL cached + // during the attempt. If the job is gone - a newer publish superseded and + // deleted it while this attempt was in flight - do not re-insert it, or a + // superseded (obsolete) profile/avatar would be retried and eventually + // sent after a newer publish intent. + guard var updated = await latestJob(id: job.id) else { + Log.error("ProfilePublisher job \(job.id) failed but was superseded/deleted, skipping reschedule: \(error)") + return + } + updated.attemptCount += 1 + updated.lastError = "\(error)" + updated.nextAttemptAt = now().addingTimeInterval(backoff.delay(forAttempt: updated.attemptCount)) + updated.state = .pending + updated.updatedAt = now() + try? await publishStore.update(updated) + Log.error("ProfilePublisher job \(job.id) failed (attempt \(updated.attemptCount)): \(error)") + } + + private func latestJob(id: String) async -> DBProfilePublishJob? { + try? await publishStore.job(id: id) + } + + private enum PublishDropError: Error { + case staleSource + case conversationGone + } +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilesRepository.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilesRepository.swift new file mode 100644 index 000000000..5c8d86c8b --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilesRepository.swift @@ -0,0 +1,402 @@ +import Combine +import Foundation +import GRDB + +/// Canonical source of identity (name + avatar) for rendering. Owns a warmed +/// in-memory cache over the profile stores, merges inbound events by precedence +/// and recency, and authors the current user's own profile. +/// +/// Seeded and running, but not yet read by any view - the cutover flips +/// rendering onto it. `ProfileBackfill` (separate) seeds the stores from legacy +/// `DBMemberProfile` rows before `warmUp`, and the durable publisher is attached +/// via `bind(session:)` for self-publishing. +public actor ProfilesRepository { + private let profileStore: any ProfileStoreProtocol + private let selfProfileStore: any SelfProfileStoreProtocol + private let databaseReader: any DatabaseReader + private let conversationLocalStateWriter: any ConversationLocalStateWriterProtocol + private let selfInboxIdProvider: @Sendable () async -> String? + private let publisher: ProfilePublisher + + private var identities: [String: DBProfile] = [:] + private var avatarsByInbox: [String: [String: DBProfileAvatar]] = [:] + private var cachedSelf: DBMyProfile? + private var cachedSelfInboxId: String? + private var warmedUp: Bool = false + + private let changesRelay: ProfileChangesRelay = .init() + + /// Emits the `inboxId` whose identity or avatar changed. A coarse + /// invalidation signal; the per-inbox reactive reads (`profilePublisher`) + /// are the preferred subscription for rendering. + nonisolated var profileChanges: AnyPublisher { + changesRelay.subject.eraseToAnyPublisher() + } + + /// `selfInboxIdProvider` resolves the current user's inbox id, which becomes + /// available asynchronously (after inbox-ready). The repository caches the + /// first non-nil result. This lets the DI construct the repository + /// synchronously while the inbox id resolves lazily, matching the + /// `ConnectionServicesStore` pattern. + init( + profileStore: any ProfileStoreProtocol, + selfProfileStore: any SelfProfileStoreProtocol, + publishStore: any ProfilePublishStoreProtocol, + databaseReader: any DatabaseReader, + conversationLocalStateWriter: any ConversationLocalStateWriterProtocol, + selfInboxIdProvider: @escaping @Sendable () async -> String? + ) { + self.profileStore = profileStore + self.selfProfileStore = selfProfileStore + self.databaseReader = databaseReader + self.conversationLocalStateWriter = conversationLocalStateWriter + self.selfInboxIdProvider = selfInboxIdProvider + self.publisher = ProfilePublisher( + publishStore: publishStore, + profileStore: profileStore, + selfProfileStore: selfProfileStore, + conversationLocalStateWriter: conversationLocalStateWriter, + selfInboxIdProvider: selfInboxIdProvider + ) + } + + // MARK: - Reactive reads + + /// Reactive identity for one inbox, hydrated fresh from the stores via + /// `ValueObservation`. This is the read path ViewModels subscribe to; it does + /// not depend on the in-memory cache, so it is always current. + public nonisolated func profilePublisher(inboxId: String) -> AnyPublisher { + ValueObservation + // Handle the fetch per-element: a transient error yields a default + // rather than throwing, which would fail the observation and, via the + // terminal catch, complete the stream - freezing this reactive read + // for the subscriber's lifetime. The trailing catch remains only as + // the Error -> Never conversion for an unrecoverable database error. + .tracking { db -> UnifiedProfile in + do { + return try Self.fetchProfile(db, inboxId: inboxId) + } catch { + return .empty(inboxId: inboxId) + } + } + .removeDuplicates() + .publisher(in: databaseReader) + .catch { _ in Just(UnifiedProfile.empty(inboxId: inboxId)) } + .eraseToAnyPublisher() + } + + nonisolated func profilesPublisher(inboxIds: [String]) -> AnyPublisher<[String: UnifiedProfile], Never> { + ValueObservation + .tracking { db -> [String: UnifiedProfile] in + do { + var result: [String: UnifiedProfile] = [:] + for inboxId in inboxIds { + result[inboxId] = try Self.fetchProfile(db, inboxId: inboxId) + } + return result + } catch { + return [:] + } + } + .removeDuplicates() + .publisher(in: databaseReader) + .catch { _ in Just([:]) } + .eraseToAnyPublisher() + } + + nonisolated func selfProfilePublisher() -> AnyPublisher { + ValueObservation + // Scope to the current user's row: resolve the current inbox id from + // `DBInbox` inside the read so a leftover `DBMyProfile` row from a + // previous account can't surface as the current user. Handled + // per-element so a transient error doesn't terminate the stream. + .tracking { db -> UnifiedProfile? in + do { + guard let inboxId = try DBInbox.currentInboxId(db) else { return nil } + return try Self.fetchSelfProfile(db, inboxId: inboxId) + } catch { + return nil + } + } + .removeDuplicates() + .publisher(in: databaseReader) + .catch { _ in Just(nil) } + .eraseToAnyPublisher() + } + + static func fetchProfile(_ db: Database, inboxId: String) throws -> UnifiedProfile { + let identity = try DBProfile.fetchOne(db, inboxId: inboxId) + let avatars = try DBProfileAvatar.fetchAll(db, inboxId: inboxId) + return UnifiedProfile.hydrate(identity: identity, avatarRows: avatars, inboxId: inboxId) + } + + static func fetchSelfProfile(_ db: Database, inboxId: String) throws -> UnifiedProfile? { + // Scope to the current user's row. `DBMyProfile.fetchOne(db)` with no + // predicate returns whichever row exists, so a leftover row from a + // previous account could otherwise surface as the current user. + guard let selfRow = try DBMyProfile + .filter(DBMyProfile.Columns.inboxId == inboxId) + .fetchOne(db) else { return nil } + let avatars = try DBProfileAvatar.fetchAll(db, inboxId: selfRow.inboxId) + return UnifiedProfile( + inboxId: selfRow.inboxId, + name: selfRow.name, + memberKind: nil, + metadata: selfRow.metadata, + avatars: UnifiedProfile.avatarMap(from: avatars), + updatedAt: selfRow.updatedAt + ) + } + + func warmUp() async { + guard !warmedUp else { return } + _ = await resolveSelfInboxId() + do { + for identity in try await profileStore.allIdentities() { + identities[identity.inboxId] = identity + } + for row in try await profileStore.allAvatars() { + avatarsByInbox[row.inboxId, default: [:]][row.conversationId] = row + } + cachedSelf = try await selfProfileStore.load() + warmedUp = true + } catch { + Log.error("ProfilesRepository.warmUp failed: \(error)") + } + } + + private func resolveSelfInboxId() async -> String? { + if let cachedSelfInboxId { return cachedSelfInboxId } + let resolved = await selfInboxIdProvider() + cachedSelfInboxId = resolved + return resolved + } + + // MARK: - Reads + + func profile(inboxId: String) -> UnifiedProfile { + let rows = avatarsByInbox[inboxId].map { Array($0.values) } ?? [] + return UnifiedProfile.hydrate(identity: identities[inboxId], avatarRows: rows, inboxId: inboxId) + } + + func profiles(inboxIds: [String]) -> [String: UnifiedProfile] { + var result: [String: UnifiedProfile] = [:] + for inboxId in inboxIds { + result[inboxId] = profile(inboxId: inboxId) + } + return result + } + + func selfProfile() -> UnifiedProfile? { + guard let cachedSelf else { return nil } + let rows = avatarsByInbox[cachedSelf.inboxId].map { Array($0.values) } ?? [] + return UnifiedProfile( + inboxId: cachedSelf.inboxId, + name: cachedSelf.name, + memberKind: nil, + metadata: cachedSelf.metadata, + avatars: UnifiedProfile.avatarMap(from: rows), + updatedAt: cachedSelf.updatedAt + ) + } + + // MARK: - Inbound + + /// Merges one inbound identity/avatar event into the canonical stores. + /// Events authored by the current user are ignored - self identity is held + /// in `myProfile`, not `DBProfile`. + func apply(_ event: ProfileDomainEvent) async { + // Skip the current user's own echoed profile; self identity lives in + // `myProfile`. If the inbox id isn't resolvable yet, process the event + // rather than risk dropping another member's data. + if let selfId = await resolveSelfInboxId(), event.inboxId == selfId { + return + } + var changed = false + + let existingIdentity = identities[event.inboxId] + let mergedIdentity = ProfileMerge.mergeIdentity( + existing: existingIdentity, + inboxId: event.inboxId, + incoming: event.identity, + source: event.source, + sentAt: event.sentAt + ) + if mergedIdentity != existingIdentity { + do { + try await profileStore.saveIdentity(mergedIdentity) + identities[event.inboxId] = mergedIdentity + changed = true + } catch { + Log.error("ProfilesRepository.apply identity failed: \(error)") + } + } + + let existingAvatar = avatarsByInbox[event.inboxId]?[event.conversationId] + let mergedAvatar = ProfileMerge.mergeAvatar( + existing: existingAvatar, + inboxId: event.inboxId, + conversationId: event.conversationId, + incoming: event.avatar, + source: event.source, + sentAt: event.sentAt + ) + if let mergedAvatar, mergedAvatar != existingAvatar { + do { + try await profileStore.saveAvatar(mergedAvatar) + avatarsByInbox[event.inboxId, default: [:]][event.conversationId] = mergedAvatar + changed = true + } catch { + Log.error("ProfilesRepository.apply avatar failed: \(error)") + } + } + + if changed { + changesRelay.subject.send(event.inboxId) + } + } + + // MARK: - Self write + + func updateSelfProfile(_ edit: SelfProfileEdit) async throws { + guard let selfId = await resolveSelfInboxId() else { + throw ProfilesRepositoryError.selfInboxUnavailable + } + // Load the current row first so an edit before warm-up carries forward + // the existing name/metadata rather than starting from a blank profile. + // The store additionally preserves image fields atomically on save. + let existing: DBMyProfile + if let cachedSelf { + existing = cachedSelf + } else { + // Propagate a load error rather than falling back to a blank row: a + // transient read failure must not overwrite the stored name, + // metadata, and image with an empty profile. A genuinely absent row + // (nil) starts fresh. + existing = try await selfProfileStore.load() ?? DBMyProfile(inboxId: selfId) + } + let updated = edit.applied(to: existing, updatedAt: Date()) + try await selfProfileStore.save(updated) + cachedSelf = updated + changesRelay.subject.send(selfId) + } + + // MARK: - Self publish + + /// Attaches the XMTP/upload session so the durable publisher can drain. Also + /// resumes any jobs left over from a previous run. + func bind(session: any ProfilePublishSession) async { + await publisher.attach(session: session) + } + + func unbind() async { + await publisher.detach() + } + + /// Carries an upgraded user's existing global avatar (`DBMyProfile.imageData`) + /// into the publisher's avatar source so it keeps propagating to conversations + /// after the transition, instead of seeding name-only until the user re-edits. + /// No-op when there is no stored image or a source already exists. + func seedSelfAvatarSourceIfNeeded() async { + guard let selfId = await resolveSelfInboxId() else { return } + let imageData: Data? + do { + imageData = try await databaseReader.read { db -> Data? in + try DBMyProfile + .filter(DBMyProfile.Columns.inboxId == selfId) + .fetchOne(db)?.imageData + } + } catch { + return + } + guard let imageData, !imageData.isEmpty else { return } + try? await publisher.seedAvatarSourceIfAbsent(imageData) + } + + /// Records a name and/or new avatar edit locally and bumps the self + /// profile's `updatedAt`. Propagation is lazy: the edit reaches a + /// conversation only when the user next opens or sends in it (via + /// `publishMyProfileToConversation`), so it never fans out to conversations + /// the user has stopped engaging with. When a `priorityConversationId` (the + /// active conversation) is given, it is published immediately so the user + /// sees their change reflected without waiting for the next send. + public func publishMyProfile(displayName: String?, avatarBytes: Data?, priorityConversationId: String?) async throws { + let nameField: SelfProfileEdit.Field + if let displayName { + nameField = .set(displayName) + } else { + nameField = .keep + } + try await updateSelfProfile(SelfProfileEdit(name: nameField)) + if let avatarBytes { + try await publisher.updateAvatarSource(avatarBytes) + } + if let priorityConversationId { + try await publishMyProfileToConversation(priorityConversationId) + } + } + + /// Publishes the current self profile to one conversation, but only if it has + /// changed since the last profile published there. The comparison is + /// `ConversationLocalState.publishedProfileUpdatedAt` (what we last sent here) + /// against `DBMyProfile.updatedAt` (the current profile). This is the lazy + /// propagation seam: a fresh conversation (no stamp) always publishes, an + /// up-to-date conversation is a no-op, and a stale one re-publishes and + /// re-stamps. Called on conversation open and before an outgoing send, so it + /// is impossible to send a message after editing your profile without also + /// sending the profile update. + public func publishMyProfileToConversation(_ conversationId: String) async throws { + guard let selfId = await resolveSelfInboxId() else { return } + let shouldPublish = try await databaseReader.read { db -> Bool in + guard let myUpdatedAt = try DBMyProfile + .filter(DBMyProfile.Columns.inboxId == selfId) + .fetchOne(db)?.updatedAt else { + return false + } + let publishedAt = try ConversationLocalState + .filter(ConversationLocalState.Columns.conversationId == conversationId) + .fetchOne(db)?.publishedProfileUpdatedAt + guard let publishedAt else { return true } + return publishedAt < myUpdatedAt + } + guard shouldPublish else { return } + // Enqueue the publish. The conversation's publishedProfileUpdatedAt is + // stamped by the publisher only after the ProfileUpdate is actually + // delivered - never optimistically here - so a send that fails or is + // dropped (e.g. the conversation isn't fully joined yet) leaves the + // conversation stale and eligible for retry rather than being silently + // marked as up to date. + try await publisher.publishConversation(conversationId) + } + + /// Records new self metadata locally and bumps `updatedAt`. Propagation is + /// lazy per conversation, same as `publishMyProfile`. + public func publishMyProfileMetadata(_ metadata: ProfileMetadata?) async throws { + try await updateSelfProfile(SelfProfileEdit(metadata: .set(metadata))) + } + + /// Drops a conversation's avatar slots from every person's cache and the + /// store (called when a conversation is deleted). Identity is preserved. + func purgeConversationAvatars(_ conversationId: String) async { + for inboxId in Array(avatarsByInbox.keys) { + avatarsByInbox[inboxId]?[conversationId] = nil + } + do { + try await profileStore.deleteAvatars(conversationId: conversationId) + } catch { + Log.error("ProfilesRepository.purgeConversationAvatars failed: \(error)") + } + } +} + +/// Holds the change subject so the actor can vend it from a nonisolated context. +/// `PassthroughSubject` is not `Sendable`, so it cannot be an actor-isolated +/// `let` read from `nonisolated`. This box is safe: the subject is only sent to +/// from inside the actor and only read (to build the publisher) via `subject`. +private final class ProfileChangesRelay: @unchecked Sendable { + let subject: PassthroughSubject = .init() +} + +enum ProfilesRepositoryError: Error { + case selfInboxUnavailable +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/SelfProfileEdit.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/SelfProfileEdit.swift new file mode 100644 index 000000000..d8e60f0fa --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/SelfProfileEdit.swift @@ -0,0 +1,47 @@ +import Foundation + +/// A partial edit to the current user's profile. Only the fields explicitly set +/// are changed; `keep` leaves a field as-is. Lets callers update the name +/// without touching metadata, or vice versa, without read-modify-write at the +/// call site. +struct SelfProfileEdit: Sendable { + enum Field: Sendable { + case keep + case set(T) + } + + var name: Field + var metadata: Field + + init(name: Field = .keep, metadata: Field = .keep) { + self.name = name + self.metadata = metadata + } + + /// Applies the edit to an existing self profile, stamping `updatedAt`. Image + /// fields are carried through untouched (the self accessor also preserves + /// them on write); only name/metadata are editable here. + func applied(to existing: DBMyProfile, updatedAt: Date) -> DBMyProfile { + let newName: String? + if case let .set(value) = name { + newName = value + } else { + newName = existing.name + } + let newMetadata: ProfileMetadata? + if case let .set(value) = metadata { + newMetadata = value + } else { + newMetadata = existing.metadata + } + return DBMyProfile( + inboxId: existing.inboxId, + name: newName, + imageData: existing.imageData, + imageAssetIdentifier: existing.imageAssetIdentifier, + imageContentDigest: existing.imageContentDigest, + metadata: newMetadata, + updatedAt: updatedAt + ) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/Stores/ProfilePublishStore.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/Stores/ProfilePublishStore.swift new file mode 100644 index 000000000..99dd69fcb --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/Stores/ProfilePublishStore.swift @@ -0,0 +1,299 @@ +import Foundation +import GRDB + +/// Persistence for the durable profile publish queue: the current user's +/// plaintext source avatar (`DBProfileAvatarSource`) and the per-conversation +/// publish jobs (`DBProfilePublishJob`). Thin: round-trips and the queue queries +/// (`nextReadyJob`, `nextSeq`, `earliestNextAttempt`, `supersedeOlderThan`) the +/// drain loop needs. No state transitions or scheduling here; `ProfilePublisher` +/// owns those. +/// +/// Not wired into the app yet; introduced ahead of `ProfilePublisher`. +protocol ProfilePublishStoreProtocol: Sendable { + // Source image + func setSource(_ source: DBProfileAvatarSource) async throws + /// Atomically records new source bytes at `previous version + 1` and returns + /// the new version, so concurrent avatar edits can't read the same version + /// and both write it (which would defeat the stale-source supersede check). + func bumpAvatarSource(inboxId: String, plaintext: Data, updatedAt: Date) async throws -> Int64 + func source(inboxId: String) async throws -> DBProfileAvatarSource? + func clearSource(inboxId: String) async throws + + // Job queue + func enqueue(_ job: DBProfilePublishJob) async throws + /// Atomically assigns the next `seq`, inserts the job the closure builds with + /// it, and supersedes that conversation's older non-done jobs - all in one + /// write so concurrent enqueues can't collide on `seq`. + func enqueueNext(_ makeJob: @Sendable @escaping (Int64) -> DBProfilePublishJob) async throws + func update(_ job: DBProfilePublishJob) async throws + func job(id: String) async throws -> DBProfilePublishJob? + /// The next `pending` job whose `nextAttemptAt` has passed, lowest `seq` + /// first. Excludes `uploading` (in-flight) and `done` jobs so a claimed job + /// is never handed out twice. Fetch only; the publisher transitions state + /// via `update`. + func nextReadyJob(now: Date) async throws -> DBProfilePublishJob? + /// Returns any `uploading` jobs to `pending`, e.g. after a drain was + /// interrupted mid-flight, so they become eligible for retry. + func reclaimStalledJobs() async throws + func activeJobs() async throws -> [DBProfilePublishJob] + func jobs(conversationId: String) async throws -> [DBProfilePublishJob] + func nextSeq() async throws -> Int64 + func earliestNextAttempt() async throws -> Date? + func deleteJob(id: String) async throws + func deleteJobs(conversationId: String) async throws + /// Drops non-done jobs for a conversation older than `seq`, so a newer + /// publish intent supersedes stale ones. + func supersedeOlderThan(conversationId: String, seq: Int64) async throws + func deleteAll() async throws +} + +final class GRDBProfilePublishStore: ProfilePublishStoreProtocol { + private let databaseWriter: any DatabaseWriter + private let databaseReader: any DatabaseReader + + init(databaseWriter: any DatabaseWriter, databaseReader: any DatabaseReader) { + self.databaseWriter = databaseWriter + self.databaseReader = databaseReader + } + + func setSource(_ source: DBProfileAvatarSource) async throws { + try await databaseWriter.write { db in + try source.save(db) + } + } + + func bumpAvatarSource(inboxId: String, plaintext: Data, updatedAt: Date) async throws -> Int64 { + try await databaseWriter.write { db in + let existing = try DBProfileAvatarSource.fetchOne(db, inboxId: inboxId) + let version = (existing?.version ?? 0) + 1 + try DBProfileAvatarSource(inboxId: inboxId, plaintext: plaintext, version: version, updatedAt: updatedAt).save(db) + return version + } + } + + func source(inboxId: String) async throws -> DBProfileAvatarSource? { + try await databaseReader.read { db in + try DBProfileAvatarSource.fetchOne(db, inboxId: inboxId) + } + } + + func clearSource(inboxId: String) async throws { + try await databaseWriter.write { db in + _ = try DBProfileAvatarSource.deleteOne(db, key: inboxId) + } + } + + func enqueue(_ job: DBProfilePublishJob) async throws { + try await databaseWriter.write { db in + try job.save(db) + } + } + + func enqueueNext(_ makeJob: @Sendable @escaping (Int64) -> DBProfilePublishJob) async throws { + try await databaseWriter.write { db in + let seq = try Int64.fetchOne(db, sql: "SELECT COALESCE(MAX(seq), 0) + 1 FROM profilePublishJob") ?? 1 + let job = makeJob(seq) + try job.save(db) + _ = try DBProfilePublishJob + .filter(DBProfilePublishJob.Columns.conversationId == job.conversationId) + .filter(DBProfilePublishJob.Columns.seq < seq) + .deleteAll(db) + } + } + + func update(_ job: DBProfilePublishJob) async throws { + try await databaseWriter.write { db in + try job.save(db) + } + } + + func job(id: String) async throws -> DBProfilePublishJob? { + try await databaseReader.read { db in + try DBProfilePublishJob.fetchOne(db, id: id) + } + } + + func nextReadyJob(now: Date) async throws -> DBProfilePublishJob? { + try await databaseReader.read { db in + try DBProfilePublishJob + .filter(DBProfilePublishJob.Columns.state == ProfilePublishJobState.pending.rawValue) + .filter(DBProfilePublishJob.Columns.nextAttemptAt <= now) + .order(DBProfilePublishJob.Columns.seq) + .fetchOne(db) + } + } + + func reclaimStalledJobs() async throws { + try await databaseWriter.write { db in + _ = try DBProfilePublishJob + .filter(DBProfilePublishJob.Columns.state == ProfilePublishJobState.uploading.rawValue) + .updateAll(db, DBProfilePublishJob.Columns.state.set(to: ProfilePublishJobState.pending.rawValue)) + } + } + + func activeJobs() async throws -> [DBProfilePublishJob] { + try await databaseReader.read { db in + try DBProfilePublishJob + .filter(DBProfilePublishJob.Columns.state != ProfilePublishJobState.done.rawValue) + .order(DBProfilePublishJob.Columns.seq) + .fetchAll(db) + } + } + + func jobs(conversationId: String) async throws -> [DBProfilePublishJob] { + try await databaseReader.read { db in + try DBProfilePublishJob + .filter(DBProfilePublishJob.Columns.conversationId == conversationId) + .order(DBProfilePublishJob.Columns.seq) + .fetchAll(db) + } + } + + func nextSeq() async throws -> Int64 { + try await databaseReader.read { db in + try Int64.fetchOne(db, sql: "SELECT COALESCE(MAX(seq), 0) + 1 FROM profilePublishJob") ?? 1 + } + } + + func earliestNextAttempt() async throws -> Date? { + try await databaseReader.read { db in + try Date.fetchOne( + db, + sql: "SELECT MIN(nextAttemptAt) FROM profilePublishJob WHERE state <> ?", + arguments: [ProfilePublishJobState.done.rawValue] + ) + } + } + + func deleteJob(id: String) async throws { + try await databaseWriter.write { db in + _ = try DBProfilePublishJob.deleteOne(db, key: id) + } + } + + func deleteJobs(conversationId: String) async throws { + try await databaseWriter.write { db in + _ = try DBProfilePublishJob + .filter(DBProfilePublishJob.Columns.conversationId == conversationId) + .deleteAll(db) + } + } + + func supersedeOlderThan(conversationId: String, seq: Int64) async throws { + try await databaseWriter.write { db in + _ = try DBProfilePublishJob + .filter(DBProfilePublishJob.Columns.conversationId == conversationId) + .filter(DBProfilePublishJob.Columns.seq < seq) + .deleteAll(db) + } + } + + func deleteAll() async throws { + try await databaseWriter.write { db in + _ = try DBProfilePublishJob.deleteAll(db) + _ = try DBProfileAvatarSource.deleteAll(db) + } + } +} + +actor InMemoryProfilePublishStore: ProfilePublishStoreProtocol { + private var sourcesByInbox: [String: DBProfileAvatarSource] = [:] + private var jobsById: [String: DBProfilePublishJob] = [:] + + func setSource(_ source: DBProfileAvatarSource) { + sourcesByInbox[source.inboxId] = source + } + + func bumpAvatarSource(inboxId: String, plaintext: Data, updatedAt: Date) -> Int64 { + let version = (sourcesByInbox[inboxId]?.version ?? 0) + 1 + sourcesByInbox[inboxId] = DBProfileAvatarSource(inboxId: inboxId, plaintext: plaintext, version: version, updatedAt: updatedAt) + return version + } + + func source(inboxId: String) -> DBProfileAvatarSource? { + sourcesByInbox[inboxId] + } + + func clearSource(inboxId: String) { + sourcesByInbox[inboxId] = nil + } + + func enqueue(_ job: DBProfilePublishJob) { + jobsById[job.id] = job + } + + func enqueueNext(_ makeJob: @Sendable @escaping (Int64) -> DBProfilePublishJob) { + let seq = (jobsById.values.map(\.seq).max() ?? 0) + 1 + let job = makeJob(seq) + jobsById[job.id] = job + for entry in jobsById where entry.value.conversationId == job.conversationId && entry.value.seq < seq { + jobsById[entry.key] = nil + } + } + + func update(_ job: DBProfilePublishJob) { + jobsById[job.id] = job + } + + func job(id: String) -> DBProfilePublishJob? { + jobsById[id] + } + + func nextReadyJob(now: Date) -> DBProfilePublishJob? { + jobsById.values + .filter { $0.state == .pending && $0.nextAttemptAt <= now } + .min { $0.seq < $1.seq } + } + + func reclaimStalledJobs() { + for (id, job) in jobsById where job.state == .uploading { + var updated = job + updated.state = .pending + jobsById[id] = updated + } + } + + func activeJobs() -> [DBProfilePublishJob] { + jobsById.values + .filter { $0.state != .done } + .sorted { $0.seq < $1.seq } + } + + func jobs(conversationId: String) -> [DBProfilePublishJob] { + jobsById.values + .filter { $0.conversationId == conversationId } + .sorted { $0.seq < $1.seq } + } + + func nextSeq() -> Int64 { + (jobsById.values.map(\.seq).max() ?? 0) + 1 + } + + func earliestNextAttempt() -> Date? { + jobsById.values + .filter { $0.state != .done } + .map(\.nextAttemptAt) + .min() + } + + func deleteJob(id: String) { + jobsById[id] = nil + } + + func deleteJobs(conversationId: String) { + for entry in jobsById where entry.value.conversationId == conversationId { + jobsById[entry.key] = nil + } + } + + func supersedeOlderThan(conversationId: String, seq: Int64) { + for entry in jobsById where entry.value.conversationId == conversationId && entry.value.seq < seq { + jobsById[entry.key] = nil + } + } + + func deleteAll() { + jobsById.removeAll() + sourcesByInbox.removeAll() + } +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/Stores/ProfileStore.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/Stores/ProfileStore.swift new file mode 100644 index 000000000..80fbace8e --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/Stores/ProfileStore.swift @@ -0,0 +1,183 @@ +import Foundation +import GRDB + +/// Persistence for other people's canonical identity (`DBProfile`) and their +/// per-conversation avatar slots (`DBProfileAvatar`). Thin: round-trips records +/// only. All merge/precedence logic lives in `ProfilesRepository`, and reactive +/// observation is done by the repository via GRDB `ValueObservation`, so this +/// protocol exposes no change stream. +/// +/// Not wired into the app yet; introduced ahead of `ProfilesRepository`. +protocol ProfileStoreProtocol: Sendable { + // Identity + func saveIdentity(_ profile: DBProfile) async throws + func identity(inboxId: String) async throws -> DBProfile? + func identities(inboxIds: [String]) async throws -> [DBProfile] + func allIdentities() async throws -> [DBProfile] + + // Avatars + func saveAvatar(_ avatar: DBProfileAvatar) async throws + func avatar(inboxId: String, conversationId: String) async throws -> DBProfileAvatar? + func avatars(inboxId: String) async throws -> [DBProfileAvatar] + func avatars(inboxIds: [String]) async throws -> [DBProfileAvatar] + func allAvatars() async throws -> [DBProfileAvatar] + func deleteAvatars(conversationId: String) async throws + + // Lifecycle + func deleteProfile(inboxId: String) async throws + func deleteAll() async throws +} + +final class GRDBProfileStore: ProfileStoreProtocol { + private let databaseWriter: any DatabaseWriter + private let databaseReader: any DatabaseReader + + init(databaseWriter: any DatabaseWriter, databaseReader: any DatabaseReader) { + self.databaseWriter = databaseWriter + self.databaseReader = databaseReader + } + + func saveIdentity(_ profile: DBProfile) async throws { + try await databaseWriter.write { db in + try profile.save(db) + } + } + + func identity(inboxId: String) async throws -> DBProfile? { + try await databaseReader.read { db in + try DBProfile.fetchOne(db, inboxId: inboxId) + } + } + + func identities(inboxIds: [String]) async throws -> [DBProfile] { + try await databaseReader.read { db in + try DBProfile.fetchAll(db, inboxIds: inboxIds) + } + } + + func allIdentities() async throws -> [DBProfile] { + try await databaseReader.read { db in + try DBProfile.fetchAll(db) + } + } + + func saveAvatar(_ avatar: DBProfileAvatar) async throws { + try await databaseWriter.write { db in + try avatar.save(db) + } + } + + func avatar(inboxId: String, conversationId: String) async throws -> DBProfileAvatar? { + try await databaseReader.read { db in + try DBProfileAvatar.fetchOne(db, inboxId: inboxId, conversationId: conversationId) + } + } + + func avatars(inboxId: String) async throws -> [DBProfileAvatar] { + try await databaseReader.read { db in + try DBProfileAvatar.fetchAll(db, inboxId: inboxId) + } + } + + func avatars(inboxIds: [String]) async throws -> [DBProfileAvatar] { + try await databaseReader.read { db in + try DBProfileAvatar + .filter(inboxIds.contains(DBProfileAvatar.Columns.inboxId)) + .fetchAll(db) + } + } + + func allAvatars() async throws -> [DBProfileAvatar] { + try await databaseReader.read { db in + try DBProfileAvatar.fetchAll(db) + } + } + + func deleteAvatars(conversationId: String) async throws { + try await databaseWriter.write { db in + _ = try DBProfileAvatar + .filter(DBProfileAvatar.Columns.conversationId == conversationId) + .deleteAll(db) + } + } + + func deleteProfile(inboxId: String) async throws { + try await databaseWriter.write { db in + _ = try DBProfile.deleteOne(db, key: inboxId) + _ = try DBProfileAvatar + .filter(DBProfileAvatar.Columns.inboxId == inboxId) + .deleteAll(db) + } + } + + func deleteAll() async throws { + try await databaseWriter.write { db in + _ = try DBProfile.deleteAll(db) + _ = try DBProfileAvatar.deleteAll(db) + } + } +} + +actor InMemoryProfileStore: ProfileStoreProtocol { + private var identitiesByInbox: [String: DBProfile] = [:] + private var avatarsByKey: [String: DBProfileAvatar] = [:] + + func saveIdentity(_ profile: DBProfile) { + identitiesByInbox[profile.inboxId] = profile + } + + func identity(inboxId: String) -> DBProfile? { + identitiesByInbox[inboxId] + } + + func identities(inboxIds: [String]) -> [DBProfile] { + inboxIds.compactMap { identitiesByInbox[$0] } + } + + func allIdentities() -> [DBProfile] { + Array(identitiesByInbox.values) + } + + func saveAvatar(_ avatar: DBProfileAvatar) { + avatarsByKey[avatarKey(avatar.inboxId, avatar.conversationId)] = avatar + } + + func avatar(inboxId: String, conversationId: String) -> DBProfileAvatar? { + avatarsByKey[avatarKey(inboxId, conversationId)] + } + + func avatars(inboxId: String) -> [DBProfileAvatar] { + avatarsByKey.values.filter { $0.inboxId == inboxId } + } + + func avatars(inboxIds: [String]) -> [DBProfileAvatar] { + let wanted = Set(inboxIds) + return avatarsByKey.values.filter { wanted.contains($0.inboxId) } + } + + func allAvatars() -> [DBProfileAvatar] { + Array(avatarsByKey.values) + } + + func deleteAvatars(conversationId: String) { + for entry in avatarsByKey where entry.value.conversationId == conversationId { + avatarsByKey[entry.key] = nil + } + } + + func deleteProfile(inboxId: String) { + identitiesByInbox[inboxId] = nil + for entry in avatarsByKey where entry.value.inboxId == inboxId { + avatarsByKey[entry.key] = nil + } + } + + func deleteAll() { + identitiesByInbox.removeAll() + avatarsByKey.removeAll() + } + + private func avatarKey(_ inboxId: String, _ conversationId: String) -> String { + "\(inboxId)|\(conversationId)" + } +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/Stores/SelfProfileStore.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/Stores/SelfProfileStore.swift new file mode 100644 index 000000000..28ab72164 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/Stores/SelfProfileStore.swift @@ -0,0 +1,80 @@ +import Foundation +import GRDB + +/// Persistence for the current user's identity, backed by the `myProfile` table +/// (`DBMyProfile`) - the single source of truth the "My Info" UI also writes. +/// Keyed per inbox. Writes only touch name/metadata and preserve the existing +/// image fields atomically, so this accessor and `MyGlobalProfileWriter` (which +/// owns the image) can share the table without clobbering each other. +protocol SelfProfileStoreProtocol: Sendable { + func save(_ profile: DBMyProfile) async throws + func load() async throws -> DBMyProfile? + func clear() async throws +} + +final class GRDBSelfProfileStore: SelfProfileStoreProtocol { + private let databaseWriter: any DatabaseWriter + private let databaseReader: any DatabaseReader + private let selfInboxIdProvider: @Sendable () async -> String? + + init( + databaseWriter: any DatabaseWriter, + databaseReader: any DatabaseReader, + selfInboxIdProvider: @escaping @Sendable () async -> String? + ) { + self.databaseWriter = databaseWriter + self.databaseReader = databaseReader + self.selfInboxIdProvider = selfInboxIdProvider + } + + /// Persists name/metadata for `profile.inboxId`, preserving any existing + /// image fields in the same transaction so a self name/metadata publish + /// never wipes the user's photo. + func save(_ profile: DBMyProfile) async throws { + try await databaseWriter.write { db in + let existing = try DBMyProfile + .filter(DBMyProfile.Columns.inboxId == profile.inboxId) + .fetchOne(db) + let merged = DBMyProfile( + inboxId: profile.inboxId, + name: profile.name, + imageData: existing?.imageData, + imageAssetIdentifier: existing?.imageAssetIdentifier, + imageContentDigest: existing?.imageContentDigest, + metadata: profile.metadata, + updatedAt: profile.updatedAt + ) + try merged.save(db) + } + } + + func load() async throws -> DBMyProfile? { + guard let inboxId = await selfInboxIdProvider() else { return nil } + return try await databaseReader.read { db in + try DBMyProfile.filter(DBMyProfile.Columns.inboxId == inboxId).fetchOne(db) + } + } + + func clear() async throws { + guard let inboxId = await selfInboxIdProvider() else { return } + try await databaseWriter.write { db in + _ = try DBMyProfile.filter(DBMyProfile.Columns.inboxId == inboxId).deleteAll(db) + } + } +} + +actor InMemorySelfProfileStore: SelfProfileStoreProtocol { + private var current: DBMyProfile? + + func save(_ profile: DBMyProfile) { + current = profile + } + + func load() -> DBMyProfile? { + current + } + + func clear() { + current = nil + } +} diff --git a/ConvosCore/Sources/ConvosCore/Profiles/Repository/UnifiedProfile.swift b/ConvosCore/Sources/ConvosCore/Profiles/Repository/UnifiedProfile.swift new file mode 100644 index 000000000..1613d5c9e --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Profiles/Repository/UnifiedProfile.swift @@ -0,0 +1,79 @@ +import Foundation + +/// A person's resolved identity for rendering: name + agent kind + the avatars +/// they've published, keyed by conversation. Hydrated by `ProfilesRepository` +/// from `DBProfile` + `DBProfileAvatar`. +/// +/// Temporary name: `Profile` is still the conversation-scoped presentation type +/// in use until the cutover. This type replaces it then (renamed to `Profile` +/// or kept), so `UnifiedProfile` is intentionally transitional. +public struct UnifiedProfile: Identifiable, Hashable, Sendable { + public var id: String { inboxId } + public let inboxId: String + public let name: String? + let memberKind: DBMemberKind? + public let metadata: ProfileMetadata? + let avatars: [String: Avatar] + let updatedAt: Date + + init( + inboxId: String, + name: String?, + memberKind: DBMemberKind?, + metadata: ProfileMetadata?, + avatars: [String: Avatar], + updatedAt: Date + ) { + self.inboxId = inboxId + self.name = name + self.memberKind = memberKind + self.metadata = metadata + self.avatars = avatars + self.updatedAt = updatedAt + } + + public var isAgent: Bool { + memberKind?.isAgent ?? false + } + + /// The avatar to show for a conversation: that conversation's slot if + /// present, otherwise the most recently updated slot across all + /// conversations. nil when the person has no (non-cleared) avatar anywhere. + public func displayAvatar(for conversationId: String?) -> Avatar? { + if let conversationId, let slot = avatars[conversationId] { + return slot + } + return avatars.values.max { $0.updatedAt < $1.updatedAt } + } + + static func empty(inboxId: String) -> UnifiedProfile { + UnifiedProfile(inboxId: inboxId, name: nil, memberKind: nil, metadata: nil, avatars: [:], updatedAt: .distantPast) + } + + /// Builds the conversation -> Avatar map from slot rows, dropping cleared + /// (url == nil) slots via `Avatar.from`. + static func avatarMap(from rows: [DBProfileAvatar]) -> [String: Avatar] { + var map: [String: Avatar] = [:] + for row in rows { + if let avatar = Avatar.from(url: row.url, salt: row.salt, nonce: row.nonce, key: row.encryptionKey, updatedAt: row.updatedAt) { + map[row.conversationId] = avatar + } + } + return map + } + + static func hydrate(identity: DBProfile?, avatarRows: [DBProfileAvatar], inboxId: String) -> UnifiedProfile { + let avatars = avatarMap(from: avatarRows) + guard let identity else { + return UnifiedProfile(inboxId: inboxId, name: nil, memberKind: nil, metadata: nil, avatars: avatars, updatedAt: .distantPast) + } + return UnifiedProfile( + inboxId: identity.inboxId, + name: identity.name, + memberKind: identity.memberKind, + metadata: identity.metadata, + avatars: avatars, + updatedAt: identity.updatedAt + ) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift b/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift index 27537a07f..0a7f6b723 100644 --- a/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift +++ b/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift @@ -29,6 +29,15 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { private var foregroundObserverTask: Task? private var assetRenewalTask: Task? + /// Launch-time task that builds the messaging service (which starts profile + /// services). Stored so teardown can cancel it before it can rebuild - and + /// re-register - an inbox after a `deleteAllInboxes` wipe. + private var serviceBootstrapTask: Task? + /// Task that starts profile services for a freshly built messaging service. + /// Stored/cancelled for the same reason as `serviceBootstrapTask`: it + /// re-enters `loadOrCreateService`, which would re-register an inbox on an + /// empty keychain after teardown if left to fire. + private var profileServicesTask: Task? private var cloudConnectionsCancellable: AnyCancellable? private var activeConversationObserver: NSObjectProtocol? private var staleStrangerGCTask: Task? @@ -171,6 +180,17 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { ) await renewalManager.performRenewalIfNeeded() } + + // Ensure the messaging service is built at launch. Building it starts + // its profile services (backfill + warmUp + bind the publish session) + // via loadOrCreateService's freshly-built hook, so we don't call + // startProfileServices here as well - that would double-run backfill. + // Stored + cancellation-checked so a concurrent teardown cannot let + // this rebuild (and re-register) an inbox after a delete-all wipe. + self.serviceBootstrapTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + _ = self.loadOrCreateService() + } } } @@ -179,6 +199,8 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { foregroundObserverTask?.cancel() staleStrangerGCTask?.cancel() assetRenewalTask?.cancel() + serviceBootstrapTask?.cancel() + profileServicesTask?.cancel() cloudConnectionsCancellable?.cancel() if let activeConversationObserver { NotificationCenter.default.removeObserver(activeConversationObserver) @@ -289,13 +311,13 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { /// fresh allocation, no fresh state machine, no fresh task — this /// fix collapses the pre-refactor thrash where every call rebuilt). private func loadOrCreateService() -> MessagingService { - cachedMessagingService.withLock { cached in + let result: (service: MessagingService, freshlyBuilt: Bool) = cachedMessagingService.withLock { cached in let previousWasErrored: Bool if let existing = cached { if case .error = existing.sessionStateManager.currentState { previousWasErrored = true } else { - return existing + return (existing, false) } } else { previousWasErrored = false @@ -315,7 +337,7 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { consecutiveKeychainReadFailures >= 2, let lastFailure = lastKeychainReadFailure, Date().timeIntervalSince(lastFailure) < Self.keychainRetryBackoff { - return existing + return (existing, false) } let identity: KeychainIdentity? @@ -329,7 +351,7 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { if previousWasErrored, let existing = cached { // Still unhappy; return the frozen errored service we // cached on the previous call. No rebuild thrash. - return existing + return (existing, false) } Log.error("Keychain identity read failed (\(error)); caching dedicated error-state service until next successful read.") let errored = MessagingService( @@ -343,13 +365,36 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { coreActions: coreActions ) cached = errored - return errored + return (errored, false) } let service = buildMessagingService(for: identity) cached = service - return service + return (service, true) } + if result.freshlyBuilt { + // A freshly built service has an unattached publish session. Start + // its profile services (backfill + warmUp + bind the publisher) so + // profile edits actually publish in this app session. Without this, + // only the one-time launch initialization attaches the session, so a + // service rebuilt after deleteAllInboxes / refreshAfterPairingCompleted + // would leave edits enqueued-but-undelivered until the next relaunch. + // The re-fetch returns the just-cached instance (not freshly built, + // so it does not re-trigger). startProfileServices self-gates on + // inbox-ready. + profileServicesTask?.cancel() + profileServicesTask = Task { [weak self] in + guard let self else { return } + // Cancellation is cooperative: a task cancelled while still + // queued (e.g. tearDownInbox during delete-all) still enters + // its closure. Bail before loadOrCreateService, which would + // otherwise rebuild a service from the just-wiped keychain and + // re-register a fresh inbox. + guard !Task.isCancelled else { return } + await self.loadOrCreateService().startProfileServices() + } + } + return result.service } /// Build a `MessagingService` for the keychain's current identity, or @@ -523,6 +568,14 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { } private func tearDownInbox() async throws { + // Cancel the launch-time bootstrap and the freshly-built profile-services + // task first so neither can rebuild - and re-register - an inbox after the + // wipe below clears the keychain and the cached service. + serviceBootstrapTask?.cancel() + serviceBootstrapTask = nil + profileServicesTask?.cancel() + profileServicesTask = nil + await unusedConversationCache.cancel() // Keep the cached service reference live through the entire teardown. @@ -537,6 +590,7 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { if let existing { Log.info("Tearing down authorized inbox") + await existing.stopProfileServices() await existing.stopAndDelete() await existing.waitForDeletionComplete() } @@ -550,35 +604,7 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { private func wipeResidualInboxRows() async throws { try await databaseWriter.write { db in - // Reached only from "Delete All Data" / delete-all-inboxes, which - // is a full local account reset rather than a per-conversation - // cleanup. Some tables intentionally survive conversation deletion - // during normal app use (for example `contact` uses `setNull` on - // `addedViaConversationId` so a contact can outlive a single - // source conversation), so we must explicitly clear those account- - // scoped tables here as well. - try DBCloudConnectionGrant.deleteAll(db) - try DBCloudConnection.deleteAll(db) - try DBCapabilityResolution.deleteAll(db) - try DBCreditBalance.deleteAll(db) - try DBConversationReadReceipt.deleteAll(db) - try DBPendingPhotoUpload.deleteAll(db) - try DBBuilderBundleHiddenMessage.deleteAll(db) - try DBVoiceMemoTranscript.deleteAll(db) - try AttachmentLocalState.deleteAll(db) - try DBPhotoPreferences.deleteAll(db) - try ConversationLocalState.deleteAll(db) - try DBInvite.deleteAll(db) - try DBConversationContactsSync.deleteAll(db) - try DBAgentTemplate.deleteAll(db) - try DBMessage.deleteAll(db) - try DBMemberProfile.deleteAll(db) - try DBConversationMember.deleteAll(db) - try DBContact.deleteAll(db) - try DBMember.deleteAll(db) - try DBConversation.deleteAll(db) - try DBInbox.deleteAll(db) - try DBMyProfile.deleteAll(db) + try Self.wipeAccountScopedRows(db) } } @@ -928,6 +954,52 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { } } +// MARK: - Account reset + +extension SessionManager { + /// Clears every account-scoped table in a full local reset ("Delete All + /// Data" / delete-all-inboxes). Some tables intentionally survive + /// conversation deletion during normal app use (for example `contact` + /// uses `setNull` on `addedViaConversationId` so a contact can outlive a + /// single source conversation), so a full reset must clear them here. + /// + /// Internal (not private) so the clean-slate guarantee can be unit-tested: + /// a residual row after this runs means a re-paired or different account + /// inherits stale data. + static func wipeAccountScopedRows(_ db: Database) throws { + try DBCloudConnectionGrant.deleteAll(db) + try DBCloudConnection.deleteAll(db) + try DBCapabilityResolution.deleteAll(db) + try DBCreditBalance.deleteAll(db) + try DBConversationReadReceipt.deleteAll(db) + try DBPendingPhotoUpload.deleteAll(db) + try DBBuilderBundleHiddenMessage.deleteAll(db) + try DBVoiceMemoTranscript.deleteAll(db) + try AttachmentLocalState.deleteAll(db) + try DBPhotoPreferences.deleteAll(db) + try ConversationLocalState.deleteAll(db) + try DBInvite.deleteAll(db) + try DBConversationContactsSync.deleteAll(db) + try DBAgentTemplate.deleteAll(db) + try DBMessage.deleteAll(db) + try DBMemberProfile.deleteAll(db) + try DBConversationMember.deleteAll(db) + try DBContact.deleteAll(db) + try DBMember.deleteAll(db) + try DBConversation.deleteAll(db) + try DBInbox.deleteAll(db) + try DBMyProfile.deleteAll(db) + // Canonical profile tables. Account-scoped and survive conversation + // deletion, so a full reset must clear them too; otherwise a re-paired + // or different account inherits stale identities, avatars, and queued + // publish jobs. + try DBProfile.deleteAll(db) + try DBProfileAvatar.deleteAll(db) + try DBProfileAvatarSource.deleteAll(db) + try DBProfilePublishJob.deleteAll(db) + } +} + // MARK: - Engagement-gated discard extension SessionManager { diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/ConversationLocalState.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/ConversationLocalState.swift index fbbe5d949..7cc82cc5b 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Database Models/ConversationLocalState.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/ConversationLocalState.swift @@ -18,6 +18,7 @@ struct ConversationLocalState: Codable, FetchableRecord, PersistableRecord, Hash static let wasRemoved: Column = Column(CodingKeys.wasRemoved) static let hasHadOtherMembers: Column = Column(CodingKeys.hasHadOtherMembers) static let hasSharedInvite: Column = Column(CodingKeys.hasSharedInvite) + static let publishedProfileUpdatedAt: Column = Column(CodingKeys.publishedProfileUpdatedAt) } let conversationId: String @@ -46,6 +47,41 @@ struct ConversationLocalState: Codable, FetchableRecord, PersistableRecord, Hash /// Local-only, so network sync can never clobber it; deleted with the /// conversation. Read by `ConversationEngagement.isEngaged`. let hasSharedInvite: Bool + /// The `DBMyProfile.updatedAt` of the self profile last published to this + /// conversation. The lazy profile sync compares this against the current + /// `DBMyProfile.updatedAt` to decide whether an edit still needs to be sent + /// here, so a profile change only reaches conversations the user re-engages + /// rather than fanning out to every conversation. nil means never published + /// (treated as stale). Local-only; deleted with the conversation. + let publishedProfileUpdatedAt: Date? + + init( + conversationId: String, + isPinned: Bool, + isUnread: Bool, + isUnreadUpdatedAt: Date, + isMuted: Bool, + pinnedOrder: Int?, + hidesInviteCard: Bool, + leftHostedInviteSession: Bool, + wasRemoved: Bool, + hasHadOtherMembers: Bool, + hasSharedInvite: Bool, + publishedProfileUpdatedAt: Date? = nil + ) { + self.conversationId = conversationId + self.isPinned = isPinned + self.isUnread = isUnread + self.isUnreadUpdatedAt = isUnreadUpdatedAt + self.isMuted = isMuted + self.pinnedOrder = pinnedOrder + self.hidesInviteCard = hidesInviteCard + self.leftHostedInviteSession = leftHostedInviteSession + self.wasRemoved = wasRemoved + self.hasHadOtherMembers = hasHadOtherMembers + self.hasSharedInvite = hasSharedInvite + self.publishedProfileUpdatedAt = publishedProfileUpdatedAt + } static let conversationForeignKey: ForeignKey = ForeignKey([Columns.conversationId], to: [DBConversation.Columns.id]) @@ -68,7 +104,8 @@ extension ConversationLocalState { leftHostedInviteSession: leftHostedInviteSession, wasRemoved: wasRemoved, hasHadOtherMembers: hasHadOtherMembers, - hasSharedInvite: hasSharedInvite + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt ) } func with(isPinned: Bool) -> Self { @@ -83,7 +120,8 @@ extension ConversationLocalState { leftHostedInviteSession: leftHostedInviteSession, wasRemoved: wasRemoved, hasHadOtherMembers: hasHadOtherMembers, - hasSharedInvite: hasSharedInvite + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt ) } func with(isMuted: Bool) -> Self { @@ -98,7 +136,8 @@ extension ConversationLocalState { leftHostedInviteSession: leftHostedInviteSession, wasRemoved: wasRemoved, hasHadOtherMembers: hasHadOtherMembers, - hasSharedInvite: hasSharedInvite + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt ) } func with(pinnedOrder: Int?) -> Self { @@ -113,7 +152,8 @@ extension ConversationLocalState { leftHostedInviteSession: leftHostedInviteSession, wasRemoved: wasRemoved, hasHadOtherMembers: hasHadOtherMembers, - hasSharedInvite: hasSharedInvite + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt ) } func with(hidesInviteCard: Bool) -> Self { @@ -128,7 +168,8 @@ extension ConversationLocalState { leftHostedInviteSession: leftHostedInviteSession, wasRemoved: wasRemoved, hasHadOtherMembers: hasHadOtherMembers, - hasSharedInvite: hasSharedInvite + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt ) } func with(leftHostedInviteSession: Bool) -> Self { @@ -143,7 +184,8 @@ extension ConversationLocalState { leftHostedInviteSession: leftHostedInviteSession, wasRemoved: wasRemoved, hasHadOtherMembers: hasHadOtherMembers, - hasSharedInvite: hasSharedInvite + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt ) } func with(wasRemoved: Bool) -> Self { @@ -158,7 +200,8 @@ extension ConversationLocalState { leftHostedInviteSession: leftHostedInviteSession, wasRemoved: wasRemoved, hasHadOtherMembers: hasHadOtherMembers, - hasSharedInvite: hasSharedInvite + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt ) } func with(hasHadOtherMembers: Bool) -> Self { @@ -173,7 +216,8 @@ extension ConversationLocalState { leftHostedInviteSession: leftHostedInviteSession, wasRemoved: wasRemoved, hasHadOtherMembers: hasHadOtherMembers, - hasSharedInvite: hasSharedInvite + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt ) } func with(hasSharedInvite: Bool) -> Self { @@ -188,7 +232,24 @@ extension ConversationLocalState { leftHostedInviteSession: leftHostedInviteSession, wasRemoved: wasRemoved, hasHadOtherMembers: hasHadOtherMembers, - hasSharedInvite: hasSharedInvite + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt + ) + } + func with(publishedProfileUpdatedAt: Date?) -> Self { + .init( + conversationId: conversationId, + isPinned: isPinned, + isUnread: isUnread, + isUnreadUpdatedAt: isUnreadUpdatedAt, + isMuted: isMuted, + pinnedOrder: pinnedOrder, + hidesInviteCard: hidesInviteCard, + leftHostedInviteSession: leftHostedInviteSession, + wasRemoved: wasRemoved, + hasHadOtherMembers: hasHadOtherMembers, + hasSharedInvite: hasSharedInvite, + publishedProfileUpdatedAt: publishedProfileUpdatedAt ) } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversation.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversation.swift index 915692c9d..08202a321 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversation.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversation.swift @@ -154,10 +154,10 @@ struct DBConversation: Codable, FetchableRecord, PersistableRecord, Identifiable using: creatorForeignKey ) - static let creatorProfile: HasOneThroughAssociation = hasOne( - DBMemberProfile.self, + static let creatorProfile: HasOneThroughAssociation = hasOne( + DBProfile.self, through: creator, - using: DBConversationMember.memberProfile, + using: DBConversationMember.profile, key: "conversationCreatorProfile" ) @@ -173,10 +173,10 @@ struct DBConversation: Codable, FetchableRecord, PersistableRecord, Identifiable key: "conversationMembers" ) - static let memberProfiles: HasManyThroughAssociation = hasMany( - DBMemberProfile.self, + static let memberProfiles: HasManyThroughAssociation = hasMany( + DBProfile.self, through: _members, - using: DBConversationMember.memberProfile, + using: DBConversationMember.profile, key: "conversationMemberProfiles" ) diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMember.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMember.swift index 481e5c969..ebcc29634 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMember.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMember.swift @@ -47,26 +47,45 @@ struct DBConversationMember: Codable, FetchableRecord, PersistableRecord, Hashab using: memberForeignKey ) - static let memberProfileForeignKey: ForeignKey = ForeignKey( - [Columns.conversationId, Columns.inboxId], - to: [DBMemberProfile.Columns.conversationId, DBMemberProfile.Columns.inboxId] + static let profile: HasOneAssociation = hasOne( + DBProfile.self, + key: "profile", + using: ForeignKey([Columns.inboxId], to: [DBProfile.Columns.inboxId]) ) - static let memberProfile: HasOneAssociation = hasOne( - DBMemberProfile.self, - using: memberProfileForeignKey + // Joins the newest avatar per inbox (the `profileAvatarLatest` view), keyed by + // inboxId only, so a person's latest avatar renders consistently across every + // conversation rather than each conversation's own per-conversation slot. + static let avatarSlot: HasOneAssociation = hasOne( + DBProfileAvatarLatest.self, + key: "avatarSlot", + using: ForeignKey([Columns.inboxId], to: [DBProfileAvatarLatest.Columns.inboxId]) ) - static let inviterProfileForeignKey: ForeignKey = ForeignKey( - [Columns.invitedByInboxId, Columns.conversationId], - to: [DBMemberProfile.Columns.inboxId, DBMemberProfile.Columns.conversationId] - ) - - static let inviterProfile: BelongsToAssociation = belongsTo( - DBMemberProfile.self, + static let inviterProfileIdentity: BelongsToAssociation = belongsTo( + DBProfile.self, key: "inviterProfile", - using: inviterProfileForeignKey + using: ForeignKey([Columns.invitedByInboxId], to: [DBProfile.Columns.inboxId]) ) + + // The current user is excluded from the canonical `profile` table (self + // identity is authored locally in `myProfile`), so the joins above are nil + // for the current user. These parallel joins resolve only for a local inbox + // (`myProfile` holds only local rows), letting hydration fall back to the + // self identity so the current user does not render as "Somebody" as a + // member or an inviter. Only the identity columns are selected so the + // `myProfile.imageData` blob is not dragged into every roster query. + static let myProfileIdentity: HasOneAssociation = hasOne( + DBMyProfile.self, + key: "myProfile", + using: ForeignKey([Columns.inboxId], to: [DBMyProfile.Columns.inboxId]) + ).select(DBMyProfile.Columns.inboxId, DBMyProfile.Columns.name, DBMyProfile.Columns.metadata, DBMyProfile.Columns.updatedAt) + + static let inviterMyProfileIdentity: BelongsToAssociation = belongsTo( + DBMyProfile.self, + key: "inviterMyProfile", + using: ForeignKey([Columns.invitedByInboxId], to: [DBMyProfile.Columns.inboxId]) + ).select(DBMyProfile.Columns.inboxId, DBMyProfile.Columns.name, DBMyProfile.Columns.metadata, DBMyProfile.Columns.updatedAt) } // MARK: - DBConversationMember Extensions diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMemberProfileWithRole.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMemberProfileWithRole.swift index 4e9b0df9d..1ec775cac 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMemberProfileWithRole.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMemberProfileWithRole.swift @@ -4,23 +4,59 @@ import GRDB // MARK: - ConversationMemberProfileWithRole struct DBConversationMemberProfileWithRole: Codable, FetchableRecord, Hashable { - let memberProfile: DBMemberProfile + let conversationId: String + let inboxId: String let role: MemberRole let createdAt: Date - let inviterProfile: DBMemberProfile? + let profile: DBProfile? + let avatarSlot: DBProfileAvatarLatest? + let inviterProfile: DBProfile? + let myProfile: DBMyProfile? + let inviterMyProfile: DBMyProfile? } extension DBConversationMemberProfileWithRole { + var isAgent: Bool { + profile?.memberKind?.isAgent ?? false + } + + var agentVerification: AgentVerification { + profile?.memberKind?.agentVerification ?? .unverified + } + + /// Effective display name: the canonical `profile` name, falling back to the + /// locally-authored `myProfile` for the current user, who is excluded from + /// `profile`. Reads that project the name directly (rather than through + /// `hydratedProfile()`) must use this so self does not render as "Somebody". + var resolvedName: String? { + profile?.name ?? myProfile?.name + } + + func hydratedProfile() -> Profile { + if let profile { + return Profile.from(profile: profile, avatar: avatarSlot?.asProfileAvatar, inboxId: inboxId, conversationId: conversationId) + } + // The current user is excluded from the canonical `profile` table, so + // fall back to the locally-authored self identity for this member. + return Profile.from(myProfile: myProfile, avatar: avatarSlot?.asProfileAvatar, inboxId: inboxId, conversationId: conversationId) + } + func hydrateConversationMember(currentInboxId: String) -> ConversationMember { - let profile = memberProfile.hydrateProfile() - let isAgent = memberProfile.isAgent + let invitedByProfile: Profile? + if let inviterProfile { + invitedByProfile = Profile.from(profile: inviterProfile, avatar: nil, inboxId: inviterProfile.inboxId, conversationId: conversationId) + } else if let inviterMyProfile { + invitedByProfile = Profile.from(myProfile: inviterMyProfile, avatar: nil, inboxId: inviterMyProfile.inboxId, conversationId: conversationId) + } else { + invitedByProfile = nil + } return .init( - profile: profile, + profile: hydratedProfile(), role: role, - isCurrentUser: memberProfile.inboxId == currentInboxId, + isCurrentUser: inboxId == currentInboxId, isAgent: isAgent, - agentVerification: memberProfile.agentVerification, - invitedBy: inviterProfile?.hydrateProfile(), + agentVerification: agentVerification, + invitedBy: invitedByProfile, joinedAt: createdAt ) } @@ -34,11 +70,16 @@ extension DBConversationMemberProfileWithRole { .filter(DBConversationMember.Columns.conversationId == conversationId) .filter(DBConversationMember.Columns.inboxId == inboxId) .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt, ]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) + .including(optional: DBConversationMember.myProfileIdentity) + .including(optional: DBConversationMember.inviterMyProfileIdentity) .asRequest(of: DBConversationMemberProfileWithRole.self) .fetchOne(db) } @@ -52,11 +93,16 @@ extension DBConversationMemberProfileWithRole { .filter(DBConversationMember.Columns.conversationId == conversationId) .filter(inboxIds.contains(DBConversationMember.Columns.inboxId)) .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt, ]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) + .including(optional: DBConversationMember.myProfileIdentity) + .including(optional: DBConversationMember.inviterMyProfileIdentity) .asRequest(of: DBConversationMemberProfileWithRole.self) .fetchAll(db) } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMessage.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMessage.swift index 88fff4dcd..de6e102c7 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMessage.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMessage.swift @@ -88,13 +88,6 @@ struct DBMessage: FetchableRecord, PersistableRecord, Hashable, Codable, Sendabl using: senderForeignKey ) - static let senderProfile: HasOneThroughAssociation = hasOne( - DBMemberProfile.self, - through: sender, - using: DBConversationMember.memberProfile, - key: "messageSenderProfile" - ) - static let replies: HasManyAssociation = hasMany( DBMessage.self, key: "messageReplies", diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMyProfile.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMyProfile.swift index 90f5af077..9053c2238 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMyProfile.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBMyProfile.swift @@ -47,3 +47,9 @@ struct DBMyProfile: Codable, FetchableRecord, PersistableRecord, Hashable { self.updatedAt = updatedAt } } + +extension DBMyProfile { + static func fetchAll(_ db: Database, inboxIds: [String]) throws -> [DBMyProfile] { + try fetchAll(db, keys: inboxIds) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfile.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfile.swift new file mode 100644 index 000000000..68c5d4dd4 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfile.swift @@ -0,0 +1,101 @@ +import ConvosAppData +import Foundation +import GRDB + +/// Canonical per-person identity, keyed by `inboxId`. Single source of truth for +/// a member's display name and agent kind, replacing the identity fields +/// previously read from per-conversation `DBMemberProfile` rows. Avatars live in +/// `DBProfileAvatar` because encryption is per conversation. Owned by +/// `ProfilesRepository`. +struct DBProfile: Codable, FetchableRecord, PersistableRecord, Hashable { + static let databaseTableName: String = "profile" + + enum Columns { + static let inboxId: Column = Column(CodingKeys.inboxId) + static let name: Column = Column(CodingKeys.name) + static let memberKind: Column = Column(CodingKeys.memberKind) + static let metadata: Column = Column(CodingKeys.metadata) + static let profileSource: Column = Column(CodingKeys.profileSource) + static let avatarContentDigest: Column = Column(CodingKeys.avatarContentDigest) + static let updatedAt: Column = Column(CodingKeys.updatedAt) + } + + let inboxId: String + var name: String? + var memberKind: DBMemberKind? + var metadata: ProfileMetadata? + var profileSource: ProfileSource + /// Reserved for the cross-conversation image-digest optimization (ADR 014). + /// Always nil until that work lands; declared here so the follow-up needs no + /// further migration. + var avatarContentDigest: String? + var updatedAt: Date + + init( + inboxId: String, + name: String? = nil, + memberKind: DBMemberKind? = nil, + metadata: ProfileMetadata? = nil, + profileSource: ProfileSource, + avatarContentDigest: String? = nil, + updatedAt: Date + ) { + self.inboxId = inboxId + self.name = name + self.memberKind = memberKind + self.metadata = metadata + self.profileSource = profileSource + self.avatarContentDigest = avatarContentDigest + self.updatedAt = updatedAt + } + + var isAgent: Bool { + memberKind?.isAgent ?? false + } + + var agentVerification: AgentVerification { + memberKind?.agentVerification ?? .unverified + } +} + +extension DBProfile { + static func fetchOne(_ db: Database, inboxId: String) throws -> DBProfile? { + try fetchOne(db, key: inboxId) + } + + static func fetchAll(_ db: Database, inboxIds: [String]) throws -> [DBProfile] { + try fetchAll(db, keys: inboxIds) + } +} + +extension DBProfile { + /// The backend `AgentTemplate.id` a template-backed agent was provisioned + /// from, read from `metadata`. Mirrors the accessor on `DBMemberProfile` / + /// `Profile`; empty / whitespace-only values coerce to nil. + var agentTemplateId: String? { + trimmedMetadata(Constant.templateIdKey) + } + + /// The shareable web URL for a template-backed agent (`publishedUrl`). + var agentTemplatePublishedURL: String? { + trimmedMetadata(Constant.publishedURLKey) + } + + /// The agent template's emoji, when published. + var agentTemplateEmoji: String? { + trimmedMetadata(Constant.emojiKey) + } + + private func trimmedMetadata(_ key: String) -> String? { + metadata?[key]?.stringValue.flatMap { value in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + } + + private enum Constant { + static let templateIdKey: String = "templateId" + static let publishedURLKey: String = "publishedUrl" + static let emojiKey: String = "emoji" + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfileAvatar.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfileAvatar.swift new file mode 100644 index 000000000..e6a426650 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfileAvatar.swift @@ -0,0 +1,141 @@ +import Foundation +import GRDB + +/// A person's avatar for one conversation, keyed by `(inboxId, conversationId)`. +/// Avatars are per-conversation because each is encrypted with that +/// conversation's group key, so the same source image yields a different +/// `(url, salt, nonce, encryptionKey)` per conversation. The display resolver +/// prefers the slot for the conversation being rendered and falls back to the +/// most recently updated slot. +/// +/// Not wired into rendering or sync yet; introduced ahead of the +/// `ProfilesRepository` that owns it. +struct DBProfileAvatar: Codable, FetchableRecord, PersistableRecord, Hashable { + static let databaseTableName: String = "profileAvatar" + + enum Columns { + static let inboxId: Column = Column(CodingKeys.inboxId) + static let conversationId: Column = Column(CodingKeys.conversationId) + static let url: Column = Column(CodingKeys.url) + static let salt: Column = Column(CodingKeys.salt) + static let nonce: Column = Column(CodingKeys.nonce) + static let encryptionKey: Column = Column(CodingKeys.encryptionKey) + static let profileSource: Column = Column(CodingKeys.profileSource) + static let contentDigest: Column = Column(CodingKeys.contentDigest) + static let updatedAt: Column = Column(CodingKeys.updatedAt) + static let lastRenewed: Column = Column(CodingKeys.lastRenewed) + } + + let inboxId: String + let conversationId: String + var url: String? + var salt: Data? + var nonce: Data? + var encryptionKey: Data? + var profileSource: ProfileSource + /// Reserved for the cross-conversation image-digest optimization (ADR 014). + /// Always nil until that work lands. + var contentDigest: String? + var updatedAt: Date + /// Last successful asset re-sign time for `url`, stamped by the asset + /// renewal sweep. Distinct from `updatedAt` (the merge/recency signal) so a + /// URL renewal never looks like a newer profile authorship event. `nil` means + /// never renewed, which the stale sweep treats as eligible. + var lastRenewed: Date? + + init( + inboxId: String, + conversationId: String, + url: String? = nil, + salt: Data? = nil, + nonce: Data? = nil, + encryptionKey: Data? = nil, + profileSource: ProfileSource, + contentDigest: String? = nil, + updatedAt: Date, + lastRenewed: Date? = nil + ) { + self.inboxId = inboxId + self.conversationId = conversationId + self.url = url + self.salt = salt + self.nonce = nonce + self.encryptionKey = encryptionKey + self.profileSource = profileSource + self.contentDigest = contentDigest + self.updatedAt = updatedAt + self.lastRenewed = lastRenewed + } + + var hasValidEncryptedAvatar: Bool { + guard let salt, let nonce, url != nil else { + return false + } + return salt.count == 32 && nonce.count == 12 + } +} + +extension DBProfileAvatar { + static func fetchOne( + _ db: Database, + inboxId: String, + conversationId: String + ) throws -> DBProfileAvatar? { + try fetchOne( + db, + key: [ + Columns.inboxId.name: inboxId, + Columns.conversationId.name: conversationId + ] + ) + } + + static func fetchAll(_ db: Database, inboxId: String) throws -> [DBProfileAvatar] { + try filter(Columns.inboxId == inboxId).fetchAll(db) + } +} + +/// Read-only projection of the most recently updated `profileAvatar` row per +/// inbox, backed by the `profileAvatarLatest` view (see +/// `SharedDatabaseMigrator.createProfileAvatarLatestView`). The rendering avatar +/// association joins this so a person's latest avatar renders consistently across +/// every conversation, instead of each conversation's own per-conversation slot. +struct DBProfileAvatarLatest: Codable, FetchableRecord, TableRecord, Hashable { + static let databaseTableName: String = "profileAvatarLatest" + + enum Columns { + static let inboxId: Column = Column(CodingKeys.inboxId) + static let conversationId: Column = Column(CodingKeys.conversationId) + static let url: Column = Column(CodingKeys.url) + static let salt: Column = Column(CodingKeys.salt) + static let nonce: Column = Column(CodingKeys.nonce) + static let encryptionKey: Column = Column(CodingKeys.encryptionKey) + static let profileSource: Column = Column(CodingKeys.profileSource) + static let contentDigest: Column = Column(CodingKeys.contentDigest) + static let updatedAt: Column = Column(CodingKeys.updatedAt) + } + + let inboxId: String + let conversationId: String + var url: String? + var salt: Data? + var nonce: Data? + var encryptionKey: Data? + var profileSource: ProfileSource + var contentDigest: String? + var updatedAt: Date + + var asProfileAvatar: DBProfileAvatar { + DBProfileAvatar( + inboxId: inboxId, + conversationId: conversationId, + url: url, + salt: salt, + nonce: nonce, + encryptionKey: encryptionKey, + profileSource: profileSource, + contentDigest: contentDigest, + updatedAt: updatedAt + ) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfileAvatarSource.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfileAvatarSource.swift new file mode 100644 index 000000000..dafc6139d --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfileAvatarSource.swift @@ -0,0 +1,32 @@ +import Foundation +import GRDB + +/// The current user's plaintext source avatar, keyed by `inboxId`. Held so the +/// durable publish queue can re-encrypt the same image to each conversation's +/// group key without re-prompting the user. `version` is bumped each time the +/// user sets a new avatar; publish jobs pin a version so stale jobs drop without +/// uploading. +/// +/// Not wired into publishing yet; introduced ahead of the `ProfilePublisher` +/// that owns it. +struct DBProfileAvatarSource: Codable, FetchableRecord, PersistableRecord, Hashable { + static let databaseTableName: String = "profileAvatarSource" + + enum Columns { + static let inboxId: Column = Column(CodingKeys.inboxId) + static let plaintext: Column = Column(CodingKeys.plaintext) + static let version: Column = Column(CodingKeys.version) + static let updatedAt: Column = Column(CodingKeys.updatedAt) + } + + let inboxId: String + var plaintext: Data + var version: Int64 + var updatedAt: Date +} + +extension DBProfileAvatarSource { + static func fetchOne(_ db: Database, inboxId: String) throws -> DBProfileAvatarSource? { + try fetchOne(db, key: inboxId) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfilePublishJob.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfilePublishJob.swift new file mode 100644 index 000000000..23d6ff79c --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/DBProfilePublishJob.swift @@ -0,0 +1,105 @@ +import Foundation +import GRDB + +/// Lifecycle of a single publish job. `pending` is ready to be claimed, +/// `uploading` is in flight, `done` is complete and may be reaped. +enum ProfilePublishJobState: String, Codable, Hashable { + case pending + case uploading + case done +} + +/// A durable unit of work in the profile publish queue: deliver the current +/// user's profile (name-only, or with an avatar) to one conversation. Survives +/// process death so an offline edit eventually reaches every conversation +/// exactly once. Cached crypto fields let a restart re-upload identical bytes; +/// `sourceVersion` pins the `DBProfileAvatarSource.version` the job publishes so +/// superseded jobs drop without uploading. +/// +/// Not wired into publishing yet; introduced ahead of the `ProfilePublisher` +/// that drains it. +struct DBProfilePublishJob: Codable, FetchableRecord, PersistableRecord, Hashable { + static let databaseTableName: String = "profilePublishJob" + + enum Columns { + static let id: Column = Column(CodingKeys.id) + static let seq: Column = Column(CodingKeys.seq) + static let conversationId: Column = Column(CodingKeys.conversationId) + static let sourceVersion: Column = Column(CodingKeys.sourceVersion) + static let hasAvatar: Column = Column(CodingKeys.hasAvatar) + static let state: Column = Column(CodingKeys.state) + static let ciphertext: Column = Column(CodingKeys.ciphertext) + static let salt: Column = Column(CodingKeys.salt) + static let nonce: Column = Column(CodingKeys.nonce) + static let groupKey: Column = Column(CodingKeys.groupKey) + static let filename: Column = Column(CodingKeys.filename) + static let uploadedURL: Column = Column(CodingKeys.uploadedURL) + static let attemptCount: Column = Column(CodingKeys.attemptCount) + static let nextAttemptAt: Column = Column(CodingKeys.nextAttemptAt) + static let lastError: Column = Column(CodingKeys.lastError) + static let createdAt: Column = Column(CodingKeys.createdAt) + static let updatedAt: Column = Column(CodingKeys.updatedAt) + } + + let id: String + let seq: Int64 + let conversationId: String + var sourceVersion: Int64? + var hasAvatar: Bool + var state: ProfilePublishJobState + var ciphertext: Data? + var salt: Data? + var nonce: Data? + var groupKey: Data? + var filename: String? + var uploadedURL: String? + var attemptCount: Int64 + var nextAttemptAt: Date + var lastError: String? + let createdAt: Date + var updatedAt: Date + + init( + id: String, + seq: Int64, + conversationId: String, + sourceVersion: Int64? = nil, + hasAvatar: Bool = false, + state: ProfilePublishJobState = .pending, + ciphertext: Data? = nil, + salt: Data? = nil, + nonce: Data? = nil, + groupKey: Data? = nil, + filename: String? = nil, + uploadedURL: String? = nil, + attemptCount: Int64 = 0, + nextAttemptAt: Date, + lastError: String? = nil, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.seq = seq + self.conversationId = conversationId + self.sourceVersion = sourceVersion + self.hasAvatar = hasAvatar + self.state = state + self.ciphertext = ciphertext + self.salt = salt + self.nonce = nonce + self.groupKey = groupKey + self.filename = filename + self.uploadedURL = uploadedURL + self.attemptCount = attemptCount + self.nextAttemptAt = nextAttemptAt + self.lastError = lastError + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +extension DBProfilePublishJob { + static func fetchOne(_ db: Database, id: String) throws -> DBProfilePublishJob? { + try fetchOne(db, key: id) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Database Models/ProfileSource.swift b/ConvosCore/Sources/ConvosCore/Storage/Database Models/ProfileSource.swift new file mode 100644 index 000000000..fcc38b6cd --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Storage/Database Models/ProfileSource.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Provenance of a stored profile value, used by the merge engine to decide +/// which inbound value wins. Higher precedence always overrides lower; within +/// the same precedence, recency (`updatedAt`) decides. A lower-precedence value +/// only fills a blank left by a higher one. +/// +/// Ordering, lowest to highest: `contact` (backfilled from legacy data), +/// `appData` (observed from group app-data), `profileSnapshot` (relayed by the +/// member who added others), `profileUpdate` (authored by the subject). +enum ProfileSource: String, Codable, Hashable, CaseIterable, Comparable { + case contact + case appData = "app_data" + case profileSnapshot = "profile_snapshot" + case profileUpdate = "profile_update" + + private var precedence: Int { + switch self { + case .contact: return 0 + case .appData: return 1 + case .profileSnapshot: return 2 + case .profileUpdate: return 3 + } + } + + static func < (lhs: ProfileSource, rhs: ProfileSource) -> Bool { + lhs.precedence < rhs.precedence + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBMessage+MessagePreview.swift b/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBMessage+MessagePreview.swift index c71c032d7..3009ff4ed 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBMessage+MessagePreview.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBMessage+MessagePreview.swift @@ -19,20 +19,21 @@ extension DBLastMessageWithSource { ) -> MessagePreview { let text: String let isCurrentUser = senderId == currentInboxId - let senderProfile = members.first { $0.memberProfile.inboxId == senderId } + let senderProfile = members.first { $0.inboxId == senderId } // Hoisted to a static helper so its branches don't count against // this function's cyclomatic complexity score, mirroring the same // pattern `resolvedMemberDisplayName` uses in ModelMocks.swift. let senderName = Self.resolveSenderName( isCurrentUser: isCurrentUser, inboxId: senderId, - profile: senderProfile?.memberProfile, + name: senderProfile?.resolvedName, + isAgent: senderProfile?.isAgent ?? false, contactNameResolver: contactNameResolver ) let attachmentsCount = attachmentUrls.count let attachmentsString = Self.attachmentsPreviewString(attachmentUrls: attachmentUrls, count: attachmentsCount) - let otherMemberCount = members.filter { $0.memberProfile.inboxId != currentInboxId }.count + let otherMemberCount = members.filter { $0.inboxId != currentInboxId }.count let shouldShowSenderName = conversationKind == .group && otherMemberCount > 1 switch messageType { @@ -41,7 +42,7 @@ extension DBLastMessageWithSource { case .attachments: text = Self.attachmentsPreviewText( senderName: senderName, - senderIsAgent: !isCurrentUser && senderProfile?.memberProfile.isAgent == true, + senderIsAgent: !isCurrentUser && senderProfile?.isAgent == true, attachmentUrls: attachmentUrls, attachmentsString: attachmentsString, otherMemberCount: otherMemberCount, @@ -159,13 +160,14 @@ extension DBLastMessageWithSource { private static func resolveSenderName( isCurrentUser: Bool, inboxId: String, - profile: DBMemberProfile?, + name: String?, + isAgent: Bool, contactNameResolver: (String) -> String? = { _ in nil } ) -> String { if isCurrentUser { return "You" } - if let name = profile?.name, !name.isEmpty { return name } + if let name, !name.isEmpty { return name } if let contactName = contactNameResolver(inboxId), !contactName.isEmpty { return contactName } - return profile?.isAgent == true ? "Agent" : "Somebody" + return isAgent ? "Agent" : "Somebody" } /// Builds the preview line for an attachment message. An agent sending a diff --git a/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBProfile+Profile.swift b/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBProfile+Profile.swift index 9c75395b5..fffca04bc 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBProfile+Profile.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Hydration/DBProfile+Profile.swift @@ -16,3 +16,53 @@ extension DBMemberProfile { ) } } + +extension Profile { + /// Builds a conversation-scoped `Profile` from the canonical per-inbox + /// `DBProfile` row and the per-(inbox, conversation) `DBProfileAvatar` + /// slot. `imageSourceContentDigest` is always nil here because the new + /// tables do not persist it (see ADR 014 for the deferred digest work). + static func from( + profile: DBProfile?, + avatar: DBProfileAvatar?, + inboxId: String, + conversationId: String + ) -> Profile { + Profile( + inboxId: inboxId, + conversationId: conversationId, + name: profile?.name, + avatar: avatar?.url, + avatarSalt: avatar?.salt, + avatarNonce: avatar?.nonce, + avatarKey: avatar?.encryptionKey, + isAgent: profile?.memberKind?.isAgent ?? false, + imageSourceContentDigest: nil, + metadata: profile?.metadata + ) + } + + /// Builds a conversation-scoped `Profile` from the current user's locally + /// authored `DBMyProfile` row plus a conversation avatar slot. Used when the + /// canonical `DBProfile` join is nil because the member (or inviter) is the + /// current user, who is excluded from `DBProfile`. Self is never an agent. + static func from( + myProfile: DBMyProfile?, + avatar: DBProfileAvatar?, + inboxId: String, + conversationId: String + ) -> Profile { + Profile( + inboxId: inboxId, + conversationId: conversationId, + name: myProfile?.name, + avatar: avatar?.url, + avatarSalt: avatar?.salt, + avatarNonce: avatar?.nonce, + avatarKey: avatar?.encryptionKey, + isAgent: false, + imageSourceContentDigest: nil, + metadata: myProfile?.metadata + ) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Models/Avatar.swift b/ConvosCore/Sources/ConvosCore/Storage/Models/Avatar.swift new file mode 100644 index 000000000..e4c696d96 --- /dev/null +++ b/ConvosCore/Sources/ConvosCore/Storage/Models/Avatar.swift @@ -0,0 +1,102 @@ +import Foundation + +/// A resolved avatar reference, collapsing the scattered +/// url/salt/nonce/key fields into one value. `plain` is a bare URL needing no +/// decryption; `encrypted` carries the AES-256-GCM material to decrypt the +/// ciphertext at `url`. +/// +/// Use `from(...)` to construct from optional fields: it returns nil for an +/// absent/empty URL, `.encrypted` only when all crypto fields are present and +/// correctly sized (salt 32, nonce 12, key 32), and `.plain` otherwise. This +/// removes the "four optional fields, three of which must agree" handling from +/// callers. +/// +/// Not wired into rendering yet; introduced ahead of the `ProfilesRepository` +/// and the avatar surfaces that will consume it. +public enum Avatar: Hashable, Sendable { + case plain(url: String, updatedAt: Date) + case encrypted(url: String, salt: Data, nonce: Data, key: Data, updatedAt: Date) + + static func from( + url: String?, + salt: Data?, + nonce: Data?, + key: Data?, + updatedAt: Date + ) -> Avatar? { + guard let url, !url.isEmpty else { + return nil + } + if let salt, let nonce, let key, + salt.count == Constant.saltByteCount, + nonce.count == Constant.nonceByteCount, + key.count == Constant.keyByteCount { + return .encrypted(url: url, salt: salt, nonce: nonce, key: key, updatedAt: updatedAt) + } + return .plain(url: url, updatedAt: updatedAt) + } + + public var url: String { + switch self { + case let .plain(url, _): + return url + case let .encrypted(url, _, _, _, _): + return url + } + } + + /// AES-256-GCM salt for an encrypted avatar; nil for a plain (bare-URL) + /// avatar. Paired with `nonce` and `key`, these feed a `Profile`'s + /// `avatarSalt`/`avatarNonce`/`avatarKey` so the shared image cache can + /// decrypt the ciphertext at `url`. + public var salt: Data? { + switch self { + case .plain: + return nil + case let .encrypted(_, salt, _, _, _): + return salt + } + } + + public var nonce: Data? { + switch self { + case .plain: + return nil + case let .encrypted(_, _, nonce, _, _): + return nonce + } + } + + public var key: Data? { + switch self { + case .plain: + return nil + case let .encrypted(_, _, _, key, _): + return key + } + } + + public var updatedAt: Date { + switch self { + case let .plain(_, updatedAt): + return updatedAt + case let .encrypted(_, _, _, _, updatedAt): + return updatedAt + } + } + + var isEncrypted: Bool { + switch self { + case .plain: + return false + case .encrypted: + return true + } + } + + private enum Constant { + static let saltByteCount: Int = 32 + static let nonceByteCount: Int = 12 + static let keyByteCount: Int = 32 + } +} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Repositories/ConversationsRepository.swift b/ConvosCore/Sources/ConvosCore/Storage/Repositories/ConversationsRepository.swift index dbfa9cb46..79087cbbf 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Repositories/ConversationsRepository.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Repositories/ConversationsRepository.swift @@ -239,11 +239,16 @@ extension QueryInterfaceRequest where RowDecoder == DBConversation { required: DBConversation.creator .forKey("conversationCreator") .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt, ]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) + .including(optional: DBConversationMember.myProfileIdentity) + .including(optional: DBConversationMember.inviterMyProfileIdentity) ) .including(required: DBConversation.localState) .including(optional: DBConversation.agentBuilderSummary) @@ -255,11 +260,16 @@ extension QueryInterfaceRequest where RowDecoder == DBConversation { all: DBConversation._members .forKey("conversationMembers") .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt, ]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) + .including(optional: DBConversationMember.myProfileIdentity) + .including(optional: DBConversationMember.inviterMyProfileIdentity) ) .group(DBConversation.Columns.id) .order(sql: "COALESCE(conversationLastMessageWithSource.date, conversation.createdAt) DESC") diff --git a/ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift b/ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift index 3a3835a49..d0af95a55 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift @@ -408,16 +408,28 @@ class MessagesRepository: MessagesRepositoryProtocol { }() static func fetchMemberProfiles(_ db: Database, conversationId: String) throws -> [String: MemberProfileInfo] { - let rows = try DBMemberProfile - .filter(DBMemberProfile.Columns.conversationId == conversationId) + let rows = try DBConversationMember + .filter(DBConversationMember.Columns.conversationId == conversationId) + .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, + DBConversationMember.Columns.role, + DBConversationMember.Columns.createdAt, + ]) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) + .including(optional: DBConversationMember.myProfileIdentity) + .including(optional: DBConversationMember.inviterMyProfileIdentity) + .asRequest(of: DBConversationMemberProfileWithRole.self) .fetchAll(db) var result: [String: MemberProfileInfo] = [:] for row in rows { result[row.inboxId] = MemberProfileInfo( inboxId: row.inboxId, conversationId: conversationId, - name: row.name, - avatar: row.avatar, + name: row.resolvedName, + avatar: row.avatarSlot?.url, isAgent: row.isAgent, agentVerification: row.agentVerification ) @@ -787,23 +799,55 @@ struct MemberProfileCache { init( activeProfiles: [DBConversationMemberProfileWithRole], - allProfiles: [DBMemberProfile], + historicalProfiles: [DBProfile] = [], + historicalSelfProfile: DBMyProfile? = nil, + historicalSelfAvatar: DBProfileAvatar? = nil, + conversationId: String, currentInboxId: String ) { var map: [String: ConversationMember] = [:] - map.reserveCapacity(max(activeProfiles.count, allProfiles.count)) + map.reserveCapacity(activeProfiles.count + historicalProfiles.count) for profile in activeProfiles { - map[profile.memberProfile.inboxId] = profile.hydrateConversationMember(currentInboxId: currentInboxId) + map[profile.inboxId] = profile.hydrateConversationMember(currentInboxId: currentInboxId) } - for profile in allProfiles where map[profile.inboxId] == nil { + // Members who authored messages/reactions but have since left the + // conversation are gone from the active roster, yet their canonical + // identity persists in `DBProfile` (per-inbox). Fold them in so their + // messages still render with a name and their reactions are not dropped. + // An active row always wins. + for profile in historicalProfiles where map[profile.inboxId] == nil { map[profile.inboxId] = ConversationMember( - profile: profile.hydrateProfile(), + profile: Profile.from( + profile: profile, + avatar: nil, + inboxId: profile.inboxId, + conversationId: conversationId + ), role: .member, - isCurrentUser: profile.inboxId == currentInboxId, - isAgent: profile.isAgent, - agentVerification: profile.agentVerification + isCurrentUser: !currentInboxId.isEmpty && profile.inboxId == currentInboxId, + isAgent: profile.memberKind?.isAgent ?? false, + agentVerification: profile.memberKind?.agentVerification ?? .unverified + ) + } + + // The current user is intentionally excluded from `DBProfile` (self lives + // in `DBMyProfile`). If they authored history here but have left the active + // roster, the loop above can't resolve them, so fold in the self identity + // from `DBMyProfile` to keep their own messages/reactions named. + if let historicalSelfProfile, !currentInboxId.isEmpty, map[currentInboxId] == nil { + map[currentInboxId] = ConversationMember( + profile: Profile.from( + myProfile: historicalSelfProfile, + avatar: historicalSelfAvatar, + inboxId: currentInboxId, + conversationId: conversationId + ), + role: .member, + isCurrentUser: true, + isAgent: false, + agentVerification: .unverified ) } @@ -848,7 +892,9 @@ fileprivate extension Database { optional: DBConversation.creator .forKey("conversationCreator") .select([DBConversationMember.Columns.role]) - .including(optional: DBConversationMember.memberProfile) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.myProfileIdentity) ) .including(required: DBConversation.localState) .including(optional: DBConversation.agentBuilderSummary) @@ -856,11 +902,16 @@ fileprivate extension Database { all: DBConversation._members .forKey("conversationMembers") .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt, ]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) + .including(optional: DBConversationMember.myProfileIdentity) + .including(optional: DBConversationMember.inviterMyProfileIdentity) ) .asRequest(of: LightweightConversationDetails.self) .fetchOne(self)? @@ -869,7 +920,8 @@ fileprivate extension Database { } private struct LightweightCreatorDetails: Codable, FetchableRecord, Hashable { - let memberProfile: DBMemberProfile? + let profile: DBProfile? + let avatarSlot: DBProfileAvatarLatest? let role: MemberRole } @@ -887,15 +939,21 @@ private extension LightweightConversationDetails { $0.hydrateConversationMember(currentInboxId: currentInboxId) } let creator: ConversationMember - if let creatorDetails = conversationCreator, let profile = creatorDetails.memberProfile { - let hydratedProfile = profile.hydrateProfile() - let isAgent = profile.isAgent + if let creatorDetails = conversationCreator { + let hydratedProfile = Profile.from( + profile: creatorDetails.profile, + avatar: creatorDetails.avatarSlot?.asProfileAvatar, + inboxId: conversation.creatorId, + conversationId: conversation.id + ) + let isAgent = creatorDetails.profile?.memberKind?.isAgent ?? false + let agentVerification = creatorDetails.profile?.memberKind?.agentVerification ?? .unverified creator = ConversationMember( profile: hydratedProfile, role: creatorDetails.role, - isCurrentUser: profile.inboxId == currentInboxId, + isCurrentUser: conversation.creatorId == currentInboxId, isAgent: isAgent, - agentVerification: profile.agentVerification + agentVerification: agentVerification ) } else { creator = ConversationMember( @@ -975,24 +1033,19 @@ fileprivate extension Database { let activeMemberProfiles = try DBConversationMember .filter(DBConversationMember.Columns.conversationId == conversationId) .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt, ]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) + .including(optional: DBConversationMember.myProfileIdentity) + .including(optional: DBConversationMember.inviterMyProfileIdentity) .asRequest(of: DBConversationMemberProfileWithRole.self) .fetchAll(self) - let historicalMemberProfiles = try DBMemberProfile - .filter(DBMemberProfile.Columns.conversationId == conversationId) - .fetchAll(self) - - let memberProfileCache = MemberProfileCache( - activeProfiles: activeMemberProfiles, - allProfiles: historicalMemberProfiles, - currentInboxId: currentInboxId - ) - var query = DBMessage .filter(DBMessage.Columns.conversationId == conversationId) .filter(DBMessage.Columns.messageType != DBMessageType.reaction.rawValue) @@ -1004,6 +1057,47 @@ fileprivate extension Database { let rawMessages = try query.fetchAll(self) + // Resolve members who are no longer in the active roster (e.g. removed + // members) from their canonical `DBProfile`, so their names survive. + // Covers both message/reaction *senders* and members that appear only in + // an update payload (initiator / added / removed) without ever authoring + // a message - otherwise those add/remove events render with no name. + let activeInboxIds = Set(activeMemberProfiles.map(\.inboxId)) + let senderInboxIds = try DBMessage + .filter(DBMessage.Columns.conversationId == conversationId) + .select(DBMessage.Columns.senderId, as: String.self) + .distinct() + .fetchAll(self) + let updateInboxIds: [String] = rawMessages.flatMap { message -> [String] in + guard let update = message.update else { return [] } + return [update.initiatedByInboxId] + update.addedInboxIds + update.removedInboxIds + } + let historicalInboxIds = Array(Set(senderInboxIds).union(updateInboxIds).subtracting(activeInboxIds)) + let historicalProfiles = historicalInboxIds.isEmpty + ? [] + : try DBProfile.fetchAll(self, inboxIds: historicalInboxIds) + + // The current user is never stored in `DBProfile`; their identity lives in + // `DBMyProfile`. If they authored history here but have left the active + // roster, resolve self from `DBMyProfile` (+ their self avatar slot) so + // their own messages/reactions keep the saved name/avatar. + let selfIsHistorical = !currentInboxId.isEmpty && historicalInboxIds.contains(currentInboxId) + let historicalSelfProfile = selfIsHistorical + ? try DBMyProfile.filter(DBMyProfile.Columns.inboxId == currentInboxId).fetchOne(self) + : nil + let historicalSelfAvatar = historicalSelfProfile == nil + ? nil + : try DBProfileAvatar.fetchOne(self, inboxId: currentInboxId, conversationId: conversationId) + + let memberProfileCache = MemberProfileCache( + activeProfiles: activeMemberProfiles, + historicalProfiles: historicalProfiles, + historicalSelfProfile: historicalSelfProfile, + historicalSelfAvatar: historicalSelfAvatar, + conversationId: conversationId, + currentInboxId: currentInboxId + ) + let messageIds = rawMessages.map { $0.id } let replySourceIds = rawMessages.compactMap { $0.messageType == .reply ? $0.sourceMessageId : nil } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Repositories/MyProfileRepository.swift b/ConvosCore/Sources/ConvosCore/Storage/Repositories/MyProfileRepository.swift index 953559f94..66c548d41 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Repositories/MyProfileRepository.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Repositories/MyProfileRepository.swift @@ -99,8 +99,17 @@ class MyProfileRepository: MyProfileRepositoryProtocol { cancellables.removeAll() let observation = ValueObservation - .tracking { db in - try Self.observedProfile(db, inboxId: inboxId, conversationId: conversationId) + // Handle the fetch per-element so a transient error yields a default + // rather than failing the observation, which would complete the + // stream (via replaceError) and freeze the My Profile screen until a + // re-subscribe. The trailing replaceError remains only as the + // Error -> Never conversion for an unrecoverable database error. + .tracking { db -> Profile in + do { + return try Self.observedProfile(db, inboxId: inboxId, conversationId: conversationId) + } catch { + return .empty(inboxId: inboxId, conversationId: conversationId) + } } .publisher(in: databaseReader) .replaceError(with: .empty(inboxId: inboxId, conversationId: conversationId)) @@ -144,25 +153,36 @@ class MyProfileRepository: MyProfileRepositoryProtocol { } } - /// Falls back to `DBMyProfile` (the inbox-wide profile) when no `DBMemberProfile` exists - /// for this conversation yet. Avoids an empty-profile flash on draft conversations and - /// during the brief window between `.ready` and the activate-sync write. - private static func observedProfile(_ db: Database, inboxId: String, conversationId: String) throws -> Profile { - if let member = try DBMemberProfile.fetchOne(db, conversationId: conversationId, inboxId: inboxId) { - return member.hydrateProfile() - } - if let global = try DBMyProfile - .filter(DBMyProfile.Columns.inboxId == inboxId) - .fetchOne(db) { - return Profile( - inboxId: inboxId, - conversationId: conversationId, - name: (global.name?.isEmpty ?? true) ? nil : global.name, - avatar: nil, - metadata: global.metadata - ) + /// Reads the current user's identity from the canonical `DBMyProfile` - the + /// single source of truth the "My Info" editor and every self-write path + /// (`publishMyProfile`, the settings editor) write, keyed per inbox - plus + /// the latest self avatar slot from `DBProfileAvatar`. The legacy + /// per-conversation `member_profile` row is not consulted. Reading the avatar + /// here (inside the tracked observation) means a self-avatar upload surfaces + /// on `fetch()` / `myProfilePublisher` instead of being dropped. + /// + /// Internal (not private) so the avatar-surfacing behavior can be unit-tested + /// against a seeded database. + static func observedProfile(_ db: Database, inboxId: String, conversationId: String) throws -> Profile { + guard let selfRow = try DBMyProfile.filter(DBMyProfile.Columns.inboxId == inboxId).fetchOne(db) else { + return .empty(inboxId: inboxId, conversationId: conversationId) } - return .empty(inboxId: inboxId, conversationId: conversationId) + // Newest slot for this inbox (matching the roster's newest-per-inbox + // resolution). Read the base table directly so the observation tracks it. + let avatar = try DBProfileAvatar + .filter(DBProfileAvatar.Columns.inboxId == inboxId) + .order(DBProfileAvatar.Columns.updatedAt.desc, DBProfileAvatar.Columns.conversationId.desc) + .fetchOne(db) + return Profile( + inboxId: inboxId, + conversationId: conversationId, + name: (selfRow.name?.isEmpty ?? true) ? nil : selfRow.name, + avatar: avatar?.url, + avatarSalt: avatar?.salt, + avatarNonce: avatar?.nonce, + avatarKey: avatar?.encryptionKey, + metadata: selfRow.metadata + ) } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift b/ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift index 32ceef9a6..02b180c6e 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift @@ -204,7 +204,7 @@ extension SharedDatabaseMigrator { Self.registerRemovedStateMigrations(on: &migrator) Self.registerConnectionGrantMigrations(on: &migrator) Self.registerJoinAndGenerationMigrations(on: &migrator) - Self.registerCleanupMigrations(on: &migrator) + Self.registerTailMigrations(on: &migrator) return migrator } @@ -242,7 +242,10 @@ extension SharedDatabaseMigrator { migrator.registerMigration("addConnectionGrantBundleScope", migrate: Self.addConnectionGrantBundleScope) } - private static func registerCleanupMigrations(on migrator: inout DatabaseMigrator) { + /// Tail migrations registered last so they run after every migration above. + /// Grouped into a helper to keep `createMigrator` under the function-length + /// budget. Order within: `dropRevealColumns` then the Profile-table schema. + private static func registerTailMigrations(on migrator: inout DatabaseMigrator) { migrator.registerMigration("dropRevealColumns", migrate: Self.dropRevealColumns) migrator.registerMigration( "addConversationLocalStateLeftHostedInviteSession", @@ -256,6 +259,93 @@ extension SharedDatabaseMigrator { "addConversationLocalStateHasSharedInvite", migrate: Self.addConversationLocalStateHasSharedInvite ) + migrator.registerMigration("createProfileTables", migrate: Self.createProfileTables) + migrator.registerMigration("createProfileAvatarLatestView", migrate: Self.createProfileAvatarLatestView) + migrator.registerMigration("dropSelfProfileTable", migrate: Self.dropSelfProfileTable) + migrator.registerMigration("addProfileAvatarLastRenewed", migrate: Self.addProfileAvatarLastRenewed) + migrator.registerMigration("makeProfileAvatarLatestViewDeterministic", migrate: Self.makeProfileAvatarLatestViewDeterministic) + migrator.registerMigration("addConversationLocalStatePublishedProfileUpdatedAt", migrate: Self.addConversationLocalStatePublishedProfileUpdatedAt) + } + + /// Additive, nullable column recording the `myProfile.updatedAt` of the self + /// profile last published to a conversation. The lazy profile sync compares + /// it against the current `myProfile.updatedAt` to decide whether a profile + /// edit still needs to be sent to this conversation, so edits reach only + /// conversations the user re-engages rather than fanning out to all of them. + /// `nil` means never published (treated as stale), so no backfill is needed. + /// Guarded on column existence for idempotency across dev installs. + static func addConversationLocalStatePublishedProfileUpdatedAt(_ db: Database) throws { + let hasColumn = try db.columns(in: "conversationLocalState").contains { $0.name == "publishedProfileUpdatedAt" } + guard !hasColumn else { return } + try db.alter(table: "conversationLocalState") { t in + t.add(column: "publishedProfileUpdatedAt", .datetime) + } + } + + /// Recreates `profileAvatarLatest` with the deterministic `ROW_NUMBER()` + /// definition. Dev installs that ran the earlier `GROUP BY inboxId` revision + /// have the nondeterministic view; views carry no data, so a drop+recreate is + /// safe. Fresh installs already get the deterministic view from + /// `createProfileAvatarLatestView` and this just recreates it identically. + static func makeProfileAvatarLatestViewDeterministic(_ db: Database) throws { + try db.execute(sql: "DROP VIEW IF EXISTS profileAvatarLatest") + try createProfileAvatarLatestView(db) + } + + /// Additive, nullable column tracking the last successful asset re-sign time + /// for a `profileAvatar` URL. Distinct from `updatedAt` (the merge/recency + /// signal) so the asset renewal sweep can stamp a renewal without making the + /// avatar look newly authored. `nil` means never renewed (eligible for + /// renewal), so no backfill is needed. Guarded because fresh installs already + /// get the column from `createProfileTables`, while dev installs that ran an + /// earlier revision of it need the `ALTER`. + static func addProfileAvatarLastRenewed(_ db: Database) throws { + let hasColumn = try db.columns(in: "profileAvatar").contains { $0.name == "lastRenewed" } + guard !hasColumn else { return } + try db.alter(table: "profileAvatar") { t in + t.add(column: "lastRenewed", .datetime) + } + } + + /// Drops the short-lived `selfProfile` table. Self identity was consolidated + /// onto the pre-existing `myProfile` table, so `selfProfile` is unused. + /// Guarded on existence because fresh installs never create it (an earlier + /// revision of `createProfileTables` did), while dev installs that ran that + /// revision still have it. + static func dropSelfProfileTable(_ db: Database) throws { + if try db.tableExists("selfProfile") { + try db.drop(table: "selfProfile") + } + } + + /// Creates the `profileAvatarLatest` view: exactly one deterministic + /// `profileAvatar` row per inbox - the newest by `updatedAt`, with + /// `conversationId` as a tie-breaker. The rendering avatar association reads + /// through this view so every conversation shows a person's latest avatar + /// (one image per person) rather than the per-conversation slot. Each avatar + /// row is self-contained (url + salt + nonce + encryptionKey), so its image + /// decrypts correctly regardless of which conversation is being rendered. + /// + /// `ROW_NUMBER()` (not `GROUP BY inboxId`) is required for determinism: when + /// an inbox has multiple rows at the same `MAX(updatedAt)` - e.g. + /// `ProfileBackfill` writes every legacy avatar at the epoch floor - a + /// `GROUP BY` returns bare columns from an arbitrary row in the group, so the + /// rendered avatar could be from the wrong conversation. The window function + /// picks one deterministic row per inbox. + static func createProfileAvatarLatestView(_ db: Database) throws { + try db.execute(sql: """ + CREATE VIEW IF NOT EXISTS profileAvatarLatest AS + SELECT inboxId, conversationId, url, salt, nonce, encryptionKey, profileSource, contentDigest, updatedAt + FROM ( + SELECT inboxId, conversationId, url, salt, nonce, encryptionKey, profileSource, contentDigest, updatedAt, + ROW_NUMBER() OVER ( + PARTITION BY inboxId + ORDER BY updatedAt DESC, conversationId DESC + ) AS rn + FROM profileAvatar + ) + WHERE rn = 1 + """) } /// Set-once high-water mark: true once the conversation's invite link @@ -1233,4 +1323,81 @@ extension SharedDatabaseMigrator { columns: ["displayName"] ) } + + /// Creates the canonical Profile-table schema that replaces per-conversation + /// `memberProfile` identity: `profile` (per-person name/kind/metadata), + /// `profileAvatar` (per-`(inboxId, conversationId)` encrypted avatar slot), + /// `selfProfile` (the local user's authored identity), `profileAvatarSource` + /// (the user's plaintext source image), and `profilePublishJob` (durable + /// publish queue). Additive: nothing reads these tables until the + /// `ProfilesRepository` lands. `avatarContentDigest` / `contentDigest` are + /// reserved for the cross-conversation digest optimization (ADR 014) and stay + /// nil until that work ships. Extracted as an internal static helper so the + /// migration test can drive the real create path without tripping the DEBUG + /// `eraseDatabaseOnSchemaChange`. + static func createProfileTables(_ db: Database) throws { + try db.create(table: "profile") { t in + t.column("inboxId", .text).notNull().primaryKey() + t.column("name", .text) + t.column("memberKind", .text) + t.column("metadata", .jsonText) + t.column("profileSource", .text).notNull() + t.column("avatarContentDigest", .text) + t.column("updatedAt", .datetime).notNull() + } + + try db.create(table: "profileAvatar") { t in + t.column("inboxId", .text).notNull() + t.column("conversationId", .text).notNull() + .references("conversation", onDelete: .cascade) + t.column("url", .text) + t.column("salt", .blob) + t.column("nonce", .blob) + t.column("encryptionKey", .blob) + t.column("profileSource", .text).notNull() + t.column("contentDigest", .text) + t.column("updatedAt", .datetime).notNull() + t.column("lastRenewed", .datetime) + t.primaryKey(["inboxId", "conversationId"]) + } + + try db.create(table: "profileAvatarSource") { t in + t.column("inboxId", .text).notNull().primaryKey() + t.column("plaintext", .blob).notNull() + t.column("version", .integer).notNull() + t.column("updatedAt", .datetime).notNull() + } + + try db.create(table: "profilePublishJob") { t in + t.column("id", .text).notNull().primaryKey() + t.column("seq", .integer).notNull() + t.column("conversationId", .text).notNull() + .references("conversation", onDelete: .cascade) + t.column("sourceVersion", .integer) + t.column("hasAvatar", .boolean).notNull().defaults(to: false) + t.column("state", .text).notNull().defaults(to: ProfilePublishJobState.pending.rawValue) + t.column("ciphertext", .blob) + t.column("salt", .blob) + t.column("nonce", .blob) + t.column("groupKey", .blob) + t.column("filename", .text) + t.column("uploadedURL", .text) + t.column("attemptCount", .integer).notNull().defaults(to: 0) + t.column("nextAttemptAt", .datetime).notNull() + t.column("lastError", .text) + t.column("createdAt", .datetime).notNull() + t.column("updatedAt", .datetime).notNull() + } + + try db.create( + index: "profilePublishJob_ready", + on: "profilePublishJob", + columns: ["state", "nextAttemptAt", "seq"] + ) + try db.create( + index: "profilePublishJob_conversationId", + on: "profilePublishJob", + columns: ["conversationId"] + ) + } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/AgentTimezonePublisher.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/AgentTimezonePublisher.swift index 0377ba085..587100520 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/AgentTimezonePublisher.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/AgentTimezonePublisher.swift @@ -64,8 +64,12 @@ public final class AgentTimezonePublisher: AgentTimezonePublishing, @unchecked S let agentConversationIds: [String] do { agentConversationIds = try await databaseReader.read { db in - let myConversationIds: [String] = try DBMemberProfile - .filter(DBMemberProfile.Columns.inboxId == inboxId) + // The roster (`conversation_members`) is the source of truth for + // which conversations the local user is in; the legacy per- + // conversation `DBMemberProfile` self rows are no longer reliably + // written. + let myConversationIds: [String] = try DBConversationMember + .filter(DBConversationMember.Columns.inboxId == inboxId) .fetchAll(db) .map(\.conversationId) return try myConversationIds.filter { conversationId in diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift index 460b30a7c..322b8446a 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationLocalStateWriter.swift @@ -8,6 +8,7 @@ public protocol ConversationLocalStateWriterProtocol: Sendable { func setHidesInviteCard(_ hidesInviteCard: Bool, for conversationId: String) async throws func setLeftHostedInviteSession(_ leftHostedInviteSession: Bool, for conversationId: String) async throws func setHasSharedInvite(_ hasSharedInvite: Bool, for conversationId: String) async throws + func setPublishedProfileUpdatedAt(_ publishedProfileUpdatedAt: Date?, for conversationId: String) async throws } /// @unchecked Sendable: GRDB's DatabaseWriter provides thread-safe access via write{} @@ -105,6 +106,12 @@ final class ConversationLocalStateWriter: ConversationLocalStateWriterProtocol, } } + func setPublishedProfileUpdatedAt(_ publishedProfileUpdatedAt: Date?, for conversationId: String) async throws { + try await updateLocalState(for: conversationId) { state in + state.with(publishedProfileUpdatedAt: publishedProfileUpdatedAt) + } + } + private func updateLocalState( for conversationId: String, _ update: @escaping @Sendable (ConversationLocalState) -> ConversationLocalState diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift index 9da508b3f..21abb2d18 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift @@ -10,7 +10,6 @@ public protocol ConversationStateManagerProtocol: AnyObject, DraftConversationWr func resetFromError() async - var myProfileWriter: any MyProfileWriterProtocol { get } var draftConversationRepository: any DraftConversationRepositoryProtocol { get } var conversationConsentWriter: any ConversationConsentWriterProtocol { get } var conversationLocalStateWriter: any ConversationLocalStateWriterProtocol { get } @@ -63,7 +62,6 @@ public final class ConversationStateManager: ConversationStateManagerProtocol, @ // MARK: - Dependencies - public let myProfileWriter: any MyProfileWriterProtocol public let conversationConsentWriter: any ConversationConsentWriterProtocol public let conversationLocalStateWriter: any ConversationLocalStateWriterProtocol public let conversationMetadataWriter: any ConversationMetadataWriterProtocol @@ -72,6 +70,11 @@ public final class ConversationStateManager: ConversationStateManagerProtocol, @ // MARK: - Private Properties private let sessionStateManager: any SessionStateManagerProtocol + /// Seeds a conversation with the current user's global profile when it + /// becomes ready. Injected so the concrete implementation + /// (`ProfilesRepository.publishMyProfileToConversation`) stays out of this + /// type; defaults to a no-op for tests and mocks. + private let profileConversationSeeder: @Sendable (String) async -> Void private let stateMachine: ConversationStateMachine /// Inbox IDs to add to the conversation as part of the initial create /// / resume sequence. The contacts picker flow supplies these when @@ -85,6 +88,17 @@ public final class ConversationStateManager: ConversationStateManagerProtocol, @ private var stateObservationTask: Task? private var initializationTask: Task? + /// Conversations this instance has already handed to the profile seeder. + /// `.ready` can re-emit, so this prevents launching duplicate concurrent + /// seeds for the same conversation within one manager's lifetime. + /// Conversations with an in-flight profile seed. Coalesces concurrent seeds + /// for the same conversation so a `.ready` re-emission doesn't launch a + /// duplicate, but the id is cleared when the seed completes so a later + /// re-emission can seed again. The seeder is change-aware and stamps only on + /// delivery, so a seed that couldn't publish yet (e.g. before the + /// conversation is fully joined) is retried rather than suppressed forever. + private let seedingConversationIds: OSAllocatedUnfairLock> = .init(initialState: []) + // MARK: - Initialization public init( @@ -96,10 +110,12 @@ public final class ConversationStateManager: ConversationStateManagerProtocol, @ conversationId: String? = nil, initialMemberInboxIds: [String] = [], backgroundUploadManager: any BackgroundUploadManagerProtocol = UnavailableBackgroundUploadManager(), - coreActions: any CoreActions = NoOpCoreActions() + coreActions: any CoreActions = NoOpCoreActions(), + profileConversationSeeder: @escaping @Sendable (String) async -> Void = { _ in } ) { self.sessionStateManager = sessionStateManager self.initialMemberInboxIds = initialMemberInboxIds + self.profileConversationSeeder = profileConversationSeeder let initialConversationId = conversationId ?? DBConversation.generateDraftConversationId() self.conversationIdSubject = .init(initialConversationId) @@ -120,11 +136,6 @@ public final class ConversationStateManager: ConversationStateManagerProtocol, @ ) self.conversationMetadataWriter = metadataWriter - self.myProfileWriter = MyProfileWriter( - sessionStateManager: sessionStateManager, - databaseWriter: databaseWriter - ) - self.conversationConsentWriter = ConversationConsentWriter( sessionStateManager: sessionStateManager, databaseWriter: databaseWriter, @@ -215,15 +226,17 @@ public final class ConversationStateManager: ConversationStateManagerProtocol, @ private func scheduleProfileSync(for conversationId: String) { guard !DBConversation.isDraft(id: conversationId) else { return } - let writer = myProfileWriter + // Coalesce only concurrent seeds; clear the id when the seed finishes so + // a `.ready` re-emission after completion can retry. The seeder is + // change-aware (a no-op once the profile has actually been delivered + // here), so retrying is cheap and self-limiting. + let shouldStart = seedingConversationIds.withLock { $0.insert(conversationId).inserted } + guard shouldStart else { return } + let seeder = profileConversationSeeder + let seeding = seedingConversationIds Task.detached { - await ProfileSyncCoordinator.shared.run(conversationId: conversationId) { - do { - try await writer.syncFromGlobalProfile(conversationId: conversationId) - } catch { - Log.warning("Failed to sync global profile to conversation \(conversationId): \(error.localizedDescription)") - } - } + await seeder(conversationId) + seeding.withLock { _ = $0.remove(conversationId) } } } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift index 23260a620..382bffe37 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift @@ -377,8 +377,18 @@ class ConversationWriter: ConversationWriterProtocol, @unchecked Sendable { try saveMembers(prepared.dbMembers, in: db) - // Fill gaps: only write appData profiles for members without message-sourced data + // Fill gaps from the group's app-data profiles. The canonical write is + // what rendering reads; the legacy `DBMemberProfile` write is kept + // defensively for any residual reader until that table is retired. + let selfInboxId = try DBInbox.currentInboxId(db) try prepared.memberProfiles.forEach { profile in + try Self.applyAppDataProfile( + db: db, + conversationId: prepared.dbConversation.id, + profile: profile, + selfInboxId: selfInboxId + ) + let existing = try DBMemberProfile.fetchOne( db, conversationId: prepared.dbConversation.id, @@ -395,6 +405,77 @@ class ConversationWriter: ConversationWriterProtocol, @unchecked Sendable { return saveResult } + /// Timestamp for app-data-sourced canonical writes. + /// + /// App-data profiles (the member profiles carried in a group's custom + /// metadata) have no per-value timestamp of their own, so they are stamped at + /// the Unix epoch floor - the lowest possible time. This is deliberate: + /// + /// - Cross-source precedence already guarantees an `.appData` value never + /// overrides a higher source (`profileSnapshot` / `profileUpdate`) + /// regardless of timestamp, so the floor is purely about ordering *within* + /// the `.appData` source. + /// - Seeding at the floor means any real, later-timestamped event supersedes + /// the app-data value by recency, and two app-data observations of the same + /// value do not churn `updatedAt` on every re-sync (the merge only writes + /// when the value actually changes). + /// + /// Mirrors `ProfileBackfill`'s floor for its `.contact`-sourced seed. + private static let appDataProfileFloor: Date = .init(timeIntervalSince1970: 0) + + /// Merges one app-data-sourced member profile into the canonical `profile` / + /// `profileAvatar` tables at the `.appData` source, so a member known only + /// from group app-data renders instead of showing as "Somebody". + /// + /// Fill-only by construction: `ProfileMerge` precedence + /// (`contact < appData < profileSnapshot < profileUpdate`) guarantees this + /// value never overrides a higher-source one and only fills a blank; a later + /// real `ProfileUpdate` / `ProfileSnapshot` automatically wins. The current + /// user is skipped - self identity lives in `myProfile` and is excluded from + /// `DBProfile`. + static func applyAppDataProfile( + db: Database, + conversationId: String, + profile: DBMemberProfile, + selfInboxId: String? + ) throws { + let inboxId = profile.inboxId + if let selfInboxId, inboxId == selfInboxId { return } + + // Only merge an identity when there is a usable name; app-data carries no + // memberKind/metadata, so a nameless entry would only create an empty + // identity row. The avatar is handled independently below. + if let name = profile.name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let existingIdentity = try DBProfile.fetchOne(db, inboxId: inboxId) + let mergedIdentity = ProfileMerge.mergeIdentity( + existing: existingIdentity, + inboxId: inboxId, + incoming: IncomingIdentity(name: name, memberKind: nil, metadata: nil), + source: .appData, + sentAt: appDataProfileFloor + ) + if mergedIdentity != existingIdentity { + try mergedIdentity.save(db) + } + } + + // Only a valid encrypted image is worth merging; anything else leaves the + // slot untouched (silent), so app-data never clears an existing avatar. + guard profile.hasValidEncryptedAvatar, let url = profile.avatar else { return } + let existingAvatar = try DBProfileAvatar.fetchOne(db, inboxId: inboxId, conversationId: conversationId) + let mergedAvatar = ProfileMerge.mergeAvatar( + existing: existingAvatar, + inboxId: inboxId, + conversationId: conversationId, + incoming: .set(url: url, salt: profile.avatarSalt, nonce: profile.avatarNonce, key: profile.avatarKey), + source: .appData, + sentAt: appDataProfileFloor + ) + if let mergedAvatar, mergedAvatar != existingAvatar { + try mergedAvatar.save(db) + } + } + /// Clears a persisted removed marker once a synced member list proves the /// local inbox is a member again (re-add or rejoin via invite). The /// membership gate matters: stream echoes for a group the user was @@ -1090,8 +1171,8 @@ class ConversationWriter: ConversationWriterProtocol, @unchecked Sendable { let conversationId = conversation.id let encryptionKey = try? conversation.imageEncryptionKey - var latestUpdates: [String: ProfileUpdate] = [:] - var latestSnapshot: ProfileSnapshot? + var latestUpdates: [String: (update: ProfileUpdate, sentAt: Date)] = [:] + var latestSnapshot: (snapshot: ProfileSnapshot, sentAt: Date)? for message in messages { guard let contentType = try? message.encodedContent.type else { continue } @@ -1100,9 +1181,10 @@ class ConversationWriter: ConversationWriterProtocol, @unchecked Sendable { guard let update = try? ProfileUpdateCodec().decode(content: message.encodedContent) else { continue } let inboxId = message.senderInboxId guard !inboxId.isEmpty, latestUpdates[inboxId] == nil else { continue } - latestUpdates[inboxId] = update + latestUpdates[inboxId] = (update, message.sentAt) } else if contentType == ContentTypeProfileSnapshot, latestSnapshot == nil { - latestSnapshot = try? ProfileSnapshotCodec().decode(content: message.encodedContent) + guard let snapshot = try? ProfileSnapshotCodec().decode(content: message.encodedContent) else { continue } + latestSnapshot = (snapshot, message.sentAt) } } @@ -1110,40 +1192,51 @@ class ConversationWriter: ConversationWriterProtocol, @unchecked Sendable { let resolvedSnapshot = latestSnapshot try await databaseWriter.write { db in - for (inboxId, update) in resolvedUpdates { - let profileMetadata = update.profileMetadata - try Self.applyProfileData( - db: db, conversationId: conversationId, inboxId: inboxId, - name: update.hasName ? update.name : nil, - encryptedImage: update.hasEncryptedImage ? update.encryptedImage : nil, - memberKind: update.memberKind.dbMemberKind, - metadata: profileMetadata.isEmpty ? nil : profileMetadata, + let selfInboxId = try DBInbox.currentInboxId(db) + for (inboxId, entry) in resolvedUpdates { + let metadata = entry.update.profileMetadata + try ProfileInboundApplier.apply( + db: db, + conversationId: conversationId, + event: ProfileInboundApplier.Incoming( + inboxId: inboxId, + source: .profileUpdate, + name: entry.update.hasName ? entry.update.name : nil, + avatar: .fillIfPresent(entry.update.hasEncryptedImage ? entry.update.encryptedImage : nil), + memberKind: entry.update.memberKind.dbMemberKind, + metadata: metadata.isEmpty ? nil : metadata, + receivedAt: entry.sentAt + ), + selfInboxId: selfInboxId, fallbackEncryptionKey: encryptionKey ) } - if let snapshot = resolvedSnapshot { - for memberProfile in snapshot.profiles { + if let resolvedSnapshot { + for memberProfile in resolvedSnapshot.snapshot.profiles { let inboxId = memberProfile.inboxIdString guard !inboxId.isEmpty, resolvedUpdates[inboxId] == nil else { continue } - - let existing = try DBMemberProfile.fetchOne(db, conversationId: conversationId, inboxId: inboxId) - guard existing?.name == nil, existing?.avatar == nil else { continue } - - let snapshotMetadata = memberProfile.profileMetadata - try Self.applyProfileData( - db: db, conversationId: conversationId, inboxId: inboxId, - name: memberProfile.hasName ? memberProfile.name : nil, - encryptedImage: memberProfile.hasEncryptedImage ? memberProfile.encryptedImage : nil, - memberKind: memberProfile.memberKind.dbMemberKind, - metadata: snapshotMetadata.isEmpty ? nil : snapshotMetadata, + let metadata = memberProfile.profileMetadata + try ProfileInboundApplier.apply( + db: db, + conversationId: conversationId, + event: ProfileInboundApplier.Incoming( + inboxId: inboxId, + source: .profileSnapshot, + name: memberProfile.hasName ? memberProfile.name : nil, + avatar: .fillIfPresent(memberProfile.hasEncryptedImage ? memberProfile.encryptedImage : nil), + memberKind: memberProfile.memberKind.dbMemberKind, + metadata: metadata.isEmpty ? nil : metadata, + receivedAt: resolvedSnapshot.sentAt + ), + selfInboxId: selfInboxId, fallbackEncryptionKey: encryptionKey ) } } } - let profileCount = latestUpdates.count + (latestSnapshot?.profiles.count ?? 0) + let profileCount = latestUpdates.count + (latestSnapshot?.snapshot.profiles.count ?? 0) if profileCount > 0 { Log.debug("Processed \(profileCount) profile messages from history for \(conversationId)") } @@ -1152,65 +1245,6 @@ class ConversationWriter: ConversationWriterProtocol, @unchecked Sendable { } } - private static func applyProfileData( // swiftlint:disable:this function_parameter_count - db: Database, - conversationId: String, - inboxId: String, - name: String?, - encryptedImage: EncryptedProfileImageRef?, - memberKind: DBMemberKind?, - metadata: ProfileMetadata? = nil, - fallbackEncryptionKey: Data? - ) throws { - let member = DBMember(inboxId: inboxId) - try member.save(db) - - var profile = try DBMemberProfile.fetchOne( - db, conversationId: conversationId, inboxId: inboxId - ) ?? DBMemberProfile(conversationId: conversationId, inboxId: inboxId, name: nil, avatar: nil) - - // Never clear an existing name with a name-less/blank update. This is - // the catch-up/history apply path (cold launch, reconnect, conversation - // open, backfill) (see DBMemberProfile.withInboundName). - profile = profile.withInboundName(name) - - if let image = encryptedImage, image.isValid { - profile = profile.with( - avatar: image.url, salt: image.salt, nonce: image.nonce, - key: profile.avatarKey ?? fallbackEncryptionKey - ) - } - - if let metadata, !metadata.isEmpty { - profile = profile.with(metadata: metadata) - } - - let priorMemberKind = profile.memberKind - if let memberKind { - profile = profile.with(memberKind: memberKind) - - if memberKind == .agent { - let verification = profile.hydrateProfile().verifyCachedAgentAttestation() - if verification.isVerified { - profile = profile.with(memberKind: DBMemberKind.from(agentVerification: verification)) - } - } - } - - if let priorMemberKind, priorMemberKind.agentVerification.isVerified, - !profile.agentVerification.isVerified { - profile = profile.with(memberKind: priorMemberKind) - } - - try profile.save(db) - - if profile.agentVerification.isConvosAgent, - let conversation = try DBConversation.fetchOne(db, id: conversationId), - !conversation.hasHadVerifiedAgent { - try conversation.with(hasHadVerifiedAgent: true).save(db) - } - } - private func getLastMessageTimestamp(for conversationId: String) async throws -> Int64? { try await databaseWriter.read { db in let lastMessage = DBConversation.association( diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/IncomingMessageWriter.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/IncomingMessageWriter.swift index 58d7d60b6..32be263a9 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/IncomingMessageWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/IncomingMessageWriter.swift @@ -84,7 +84,6 @@ class IncomingMessageWriter: IncomingMessageWriterProtocol, @unchecked Sendable let encodedContentType = prepared.encodedContentType let senderVerified = try Self.bootstrapSenderProfile( db: db, - conversationId: conversation.id, senderInboxId: source.senderInboxId ) @@ -382,32 +381,19 @@ class IncomingMessageWriter: IncomingMessageWriterProtocol, @unchecked Sendable return explodeSettings } - /// Ensures the sender has a row in `DBMember` and a `DBMemberProfile` for the - /// conversation. Returns true when the existing profile is already marked as a - /// verified Convos agent — used to gate persisting sensitive content types - /// whose rendering assumes the sender is trusted. + /// Ensures the sender has a row in `DBMember`. Returns true when the sender's + /// canonical profile is already marked as a verified Convos agent - used to + /// gate persisting sensitive content types whose rendering assumes the sender + /// is trusted. Verification is read from the per-inbox `profile` table, where + /// the inbound seam stores the resolved (attested) member kind. static func bootstrapSenderProfile( db: Database, - conversationId: String, senderInboxId: String ) throws -> Bool { let sender = DBMember(inboxId: senderInboxId) try sender.save(db) - let existingProfile = try DBMemberProfile.fetchOne( - db, - conversationId: conversationId, - inboxId: senderInboxId - ) - if existingProfile == nil { - let newProfile = DBMemberProfile( - conversationId: conversationId, - inboxId: senderInboxId, - name: nil, - avatar: nil - ) - try? newProfile.insert(db) - } - return existingProfile?.agentVerification.isConvosAgent ?? false + let identity = try DBProfile.fetchOne(db, inboxId: senderInboxId) + return identity?.memberKind?.agentVerification.isConvosAgent ?? false } /// Computes a sortId that places the message in chronological order within the conversation. diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/MyProfileWriter.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/MyProfileWriter.swift deleted file mode 100644 index dad424068..000000000 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/MyProfileWriter.swift +++ /dev/null @@ -1,310 +0,0 @@ -import Foundation -import GRDB -@preconcurrency import XMTPiOS - -public protocol MyProfileWriterProtocol: Sendable { - func update(displayName: String, conversationId: String) async throws - func update(avatar: ImageType?, imageSourceContentDigest: String?, conversationId: String) async throws - func update(metadata: ProfileMetadata?, conversationId: String) async throws - /// Like `update(metadata:conversationId:)` but propagates ProfileUpdate publish failures. - /// Use this when the caller needs to know whether the ProfileUpdate reached the network - /// (e.g. to roll back a dependent local write). - func updateAndPublish(metadata: ProfileMetadata?, conversationId: String) async throws - /// Reads `DBMyProfile` and writes/publishes any fields that differ from this group's - /// `DBMemberProfile`. No-op when nothing is set yet or the group already matches. - func syncFromGlobalProfile(conversationId: String) async throws -} - -public extension MyProfileWriterProtocol { - func update(avatar: ImageType?, conversationId: String) async throws { - try await update(avatar: avatar, imageSourceContentDigest: nil, conversationId: conversationId) - } -} - -enum MyProfileWriterError: Error { - case imageCompressionFailed - case profileUpdatePublishFailed(underlying: any Error) -} - -final class MyProfileWriter: MyProfileWriterProtocol, @unchecked Sendable { - private let sessionStateManager: any SessionStateManagerProtocol - private let databaseWriter: any DatabaseWriter - - init( - sessionStateManager: any SessionStateManagerProtocol, - databaseWriter: any DatabaseWriter - ) { - self.sessionStateManager = sessionStateManager - self.databaseWriter = databaseWriter - } - - func update(displayName: String, conversationId: String) async throws { - let inboxReady = try await sessionStateManager.waitForInboxReadyResult() - guard let conversation = try await inboxReady.client.conversation(with: conversationId), - case .group(let group) = conversation else { - throw ConversationMetadataError.conversationNotFound(conversationId: conversationId) - } - let trimmedDisplayName = { - var name = displayName.trimmingCharacters(in: .whitespacesAndNewlines) - if name.count > NameLimits.maxDisplayNameLength { - name = String(name.prefix(NameLimits.maxDisplayNameLength)) - } - return name - }() - let inboxId = inboxReady.client.inboxId - let name = trimmedDisplayName.isEmpty ? nil : trimmedDisplayName - let profile = try await databaseWriter.write { db in - let member = DBMember(inboxId: inboxId) - try member.save(db) - let existing = try DBMemberProfile.fetchOne(db, conversationId: conversationId, inboxId: inboxId) - // Never clear an existing name with an empty update: a blank name - // would render the local user as "Somebody". A real name still - // wins; a first write with no existing name is unaffected. Treat - // empty/whitespace as "no name provided" so we don't depend on the - // caller having already normalized it to nil. - let resolvedName: String? = (name?.isEmpty == false) ? name : existing?.name - let profile = (existing ?? .init( - conversationId: conversationId, - inboxId: inboxId, - name: resolvedName, - avatar: nil - )).with(name: resolvedName) - try profile.save(db) - return profile - } - - do { - try await group.updateProfile(profile) - } catch { - Log.warning("Failed to write profile to appData (best-effort): \(error.localizedDescription)") - } - await sendProfileUpdate(profile: profile, group: group) - } - - func update(metadata: ProfileMetadata?, conversationId: String) async throws { - do { - try await updateAndPublish(metadata: metadata, conversationId: conversationId) - } catch MyProfileWriterError.profileUpdatePublishFailed(let underlying) { - Log.warning("Failed to send ProfileUpdate message: \(underlying.localizedDescription)") - } - } - - func updateAndPublish(metadata: ProfileMetadata?, conversationId: String) async throws { - let inboxReady = try await sessionStateManager.waitForInboxReadyResult() - guard let conversation = try await inboxReady.client.conversation(with: conversationId), - case .group(let group) = conversation else { - throw ConversationMetadataError.conversationNotFound(conversationId: conversationId) - } - let inboxId = inboxReady.client.inboxId - let profile = try await databaseWriter.write { db in - let member = DBMember(inboxId: inboxId) - try member.save(db) - let profile = (try DBMemberProfile.fetchOne(db, conversationId: conversationId, inboxId: inboxId) ?? .init( - conversationId: conversationId, - inboxId: inboxId, - name: nil, - avatar: nil - )).with(metadata: metadata?.isEmpty == true ? nil : metadata) - try profile.save(db) - return profile - } - try await sendProfileUpdateThrowing(profile: profile, group: group) - do { - try await group.updateProfile(profile) - } catch { - Log.warning("Failed to write profile to appData (best-effort): \(error.localizedDescription)") - } - } - - func update(avatar: ImageType?, imageSourceContentDigest: String?, conversationId: String) async throws { - let inboxReady = try await sessionStateManager.waitForInboxReadyResult() - guard let conversation = try await inboxReady.client.conversation(with: conversationId), - case .group(let group) = conversation else { - throw ConversationMetadataError.conversationNotFound(conversationId: conversationId) - } - let inboxId = inboxReady.client.inboxId - let profile = try await databaseWriter.write { db in - let member = DBMember(inboxId: inboxId) - try member.save(db) - if let foundProfile = try DBMemberProfile.fetchOne(db, conversationId: conversationId, inboxId: inboxId) { - Log.info("Found profile: \(foundProfile)") - return foundProfile - } else { - let profile = DBMemberProfile( - conversationId: conversationId, - inboxId: inboxId, - name: nil, - avatar: nil - ) - try profile.save(db) - return profile - } - } - - guard let avatarImage = avatar else { - ImageCacheContainer.shared.removeImage(for: profile.hydrateProfile()) - let updatedProfile = profile - .with(avatar: nil, salt: nil, nonce: nil, key: nil) - .with(imageSourceContentDigest: nil) - try await databaseWriter.write { db in - try updatedProfile.save(db) - } - do { - // updateProfile merges (it preserves image fields absent on - // the incoming side), so removal goes through the explicit - // clearing API. - try await group.clearProfileAvatar(inboxId: updatedProfile.inboxId) - } catch { - Log.warning("Failed to clear avatar in appData (best-effort): \(error.localizedDescription)") - } - await sendProfileUpdate(profile: updatedProfile, group: group) - return - } - - let hydratedProfile = profile.hydrateProfile() - guard let compressedImageData = ImageCacheContainer.shared.prepareForUpload( - avatarImage, - for: hydratedProfile - ) else { - throw MyProfileWriterError.imageCompressionFailed - } - - let groupKey = try await group.ensureImageEncryptionKey() - let encryptedPayload = try ImageEncryption.encrypt( - imageData: compressedImageData, - groupKey: groupKey - ) - - let uploadedAssetUrl = try await inboxReady.apiClient.uploadAttachment( - data: encryptedPayload.ciphertext, - filename: "ep-\(UUID().uuidString).enc", - contentType: "application/octet-stream", - acl: "public-read" - ) - - let updatedProfile = profile - .with( - avatar: uploadedAssetUrl, - salt: encryptedPayload.salt, - nonce: encryptedPayload.nonce, - key: groupKey - ) - .with(imageSourceContentDigest: imageSourceContentDigest) - - do { - try await group.updateProfile(updatedProfile) - } catch { - Log.warning("Failed to write profile to appData (best-effort): \(error.localizedDescription)") - } - - if let image = ImageType(data: compressedImageData) { - ImageCacheContainer.shared.cacheAfterUpload(image, for: hydratedProfile, url: uploadedAssetUrl) - } - - try await databaseWriter.write { db in - Log.info("Updated encrypted avatar for profile: \(updatedProfile)") - try updatedProfile.save(db) - } - - await sendProfileUpdate(profile: updatedProfile, group: group) - } - - func syncFromGlobalProfile(conversationId: String) async throws { - let inboxReady = try await sessionStateManager.waitForInboxReadyResult() - let inboxId = inboxReady.client.inboxId - - let global = try await databaseWriter.read { db in - try DBMyProfile - .filter(DBMyProfile.Columns.inboxId == inboxId) - .fetchOne(db) - } - guard let global else { return } - - let member = try await databaseWriter.read { db in - try DBMemberProfile.fetchOne(db, conversationId: conversationId, inboxId: inboxId) - } - - // global.name is already trim/clamp/nil-if-empty normalized by MyGlobalProfileWriter, - // so it can be compared to member?.name directly without re-normalizing here. - // Only propagate a real name. A nil/empty global name must never be - // pushed out as a clear - it would surface as "Somebody" in the chat - // and broadcast that to everyone. global.name is already nil-if-empty - // normalized by MyGlobalProfileWriter. - if let globalName = global.name, !globalName.isEmpty, globalName != member?.name { - try await update(displayName: globalName, conversationId: conversationId) - } - - if global.imageData == nil { - // Global avatar was cleared. Propagate the removal so the per-conversation avatar - // doesn't outlive it. Requires the digest to be cleared too: image bytes that are - // merely not rehydrated yet (fresh pairing, mid-hydration launch) keep their - // digest, and propagating a "removal" the user never made would strip the avatar - // for every other member. - if global.imageContentDigest == nil, member?.avatar != nil { - try await update( - avatar: nil, - imageSourceContentDigest: nil, - conversationId: conversationId - ) - } - } else { - let needsAvatarUpload: Bool - if member?.avatar == nil { - needsAvatarUpload = true - } else { - // Compare content digests so the decision is reliable regardless of whether the - // photos library returned an asset identifier. nil-vs-something on either side - // counts as a change and triggers a re-upload. - needsAvatarUpload = member?.imageSourceContentDigest != global.imageContentDigest - } - if needsAvatarUpload, let imageData = global.imageData, let image = ImageType(data: imageData) { - try await update( - avatar: image, - imageSourceContentDigest: global.imageContentDigest, - conversationId: conversationId - ) - } - } - } - - private func sendProfileUpdate(profile: DBMemberProfile, group: XMTPiOS.Group) async { - do { - try await sendProfileUpdateThrowing(profile: profile, group: group) - } catch MyProfileWriterError.profileUpdatePublishFailed(let underlying) { - Log.warning("Failed to send ProfileUpdate message: \(underlying.localizedDescription)") - } catch { - Log.warning("Failed to send ProfileUpdate message: \(error.localizedDescription)") - } - } - - private func sendProfileUpdateThrowing(profile: DBMemberProfile, group: XMTPiOS.Group) async throws { - var update = ProfileUpdate() - if let name = profile.name { - update.name = name - } - if let encryptedRef = profile.encryptedImageRef { - update.encryptedImage = EncryptedProfileImageRef(encryptedRef) - } - if let kind = profile.memberKind { - update.memberKind = kind.protoMemberKind - } - if let metadata = profile.metadata, !metadata.isEmpty { - update.metadata = metadata.asProtoMap - } - - let codec = ProfileUpdateCodec() - let encoded: EncodedContent - do { - encoded = try codec.encode(content: update) - } catch { - throw MyProfileWriterError.profileUpdatePublishFailed(underlying: error) - } - - do { - _ = try await group.send(encodedContent: encoded) - Log.debug("Sent ProfileUpdate message for \(profile.inboxId) in \(profile.conversationId)") - } catch { - throw MyProfileWriterError.profileUpdatePublishFailed(underlying: error) - } - } -} diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/OutgoingMessageWriter.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/OutgoingMessageWriter.swift index 6c5187a73..d14e2a3ba 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/OutgoingMessageWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/OutgoingMessageWriter.swift @@ -267,6 +267,11 @@ actor OutgoingMessageWriter: OutgoingMessageWriterProtocol { let hasText: Bool } + /// Change-aware self-profile publish for this conversation, run before every + /// outgoing message. Enforces the invariant that you can't send a message + /// after editing your profile without also sending the profile update here. + /// A no-op when the profile is already current in this conversation. + private let ensureProfilePublished: (@Sendable () async -> Void)? private let sessionStateManager: any SessionStateManagerProtocol private let databaseWriter: any DatabaseWriter private let conversationId: String @@ -300,7 +305,8 @@ actor OutgoingMessageWriter: OutgoingMessageWriterProtocol { backgroundUploadManager: any BackgroundUploadManagerProtocol, attachmentLocalStateWriter: any AttachmentLocalStateWriterProtocol, contactSyncCoordinator: (any ContactSyncCoordinatorProtocol)? = nil, - coreActions: any CoreActions + coreActions: any CoreActions, + ensureProfilePublished: (@Sendable () async -> Void)? = nil ) { self.sessionStateManager = sessionStateManager self.databaseWriter = databaseWriter @@ -311,6 +317,7 @@ actor OutgoingMessageWriter: OutgoingMessageWriterProtocol { self.attachmentLocalStateWriter = attachmentLocalStateWriter self.contactSyncCoordinator = contactSyncCoordinator self.coreActions = coreActions + self.ensureProfilePublished = ensureProfilePublished } private func trackSendMetric(clientMessageId: String, hasText: Bool, attachmentMimeTypes: [String]) { @@ -2226,6 +2233,9 @@ actor OutgoingMessageWriter: OutgoingMessageWriterProtocol { while !messageQueue.isEmpty { let message = messageQueue.removeFirst() + // Publish any pending self-profile edit to this conversation before + // the message goes out, so a send always carries the current profile. + await ensureProfilePublished?() do { switch message { case .text(let queued): diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/ProfileMetadataWriter.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/ProfileMetadataWriter.swift index d704e29b1..71539117e 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/ProfileMetadataWriter.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Writers/ProfileMetadataWriter.swift @@ -2,11 +2,16 @@ import Foundation import GRDB public protocol ProfileMetadataWriterProtocol: Sendable { - /// Reads the sender's current per-conversation metadata map, applies the - /// caller's mutation closure, and republishes the merged map through - /// `MyProfileWriter.updateAndPublish`. The whole read-modify-write runs as - /// one serialized unit so two callers (e.g. a connections write and a - /// timezone write) can never interleave and clobber each other's keys. + /// Reads the current user's self-profile metadata map, applies the caller's + /// mutation closure, and republishes the merged map through + /// `ProfilesRepository.publishMyProfileMetadata`, which fans it out to every + /// conversation. The whole read-modify-write runs as one serialized unit so + /// two callers (e.g. a connections write and a timezone write) can never + /// interleave and clobber each other's keys. + /// + /// `conversationId` and `inboxId` are retained on the signature for existing + /// callers; the metadata is now global (self-profile) rather than + /// per-conversation, so they no longer scope the read. func updateMetadata( conversationId: String, inboxId: String, @@ -17,22 +22,21 @@ public protocol ProfileMetadataWriterProtocol: Sendable { /// Shared serialization choke point for every per-sender /// `ProfileUpdate.metadata` write. /// -/// The `ProfileUpdate.metadata` map is per-sender, but a publish rewrites the -/// whole merged map for that sender (read existing -> merge key -> publish). -/// Two async tasks in the same process can interleave on this non-atomic -/// read-merge-write: a timezone publish and a `connections` publish both touch -/// the same `DBMemberProfile`. If they overlap, the later write overwrites the -/// earlier with a stale copy of the key the other task just set -- silent data -/// loss. +/// The self-profile metadata map is rewritten wholesale on each publish (read +/// existing -> merge key -> publish). Two async tasks in the same process can +/// interleave on this non-atomic read-merge-write: a timezone publish and a +/// `connections` publish both touch the same `selfProfile` row. If they overlap, +/// the later write overwrites the earlier with a stale copy of the key the other +/// task just set -- silent data loss. /// -/// To prevent that, all metadata map writes for a given sender/conversation go -/// through one shared instance of this class. `@MainActor` keeps the bookkeeping -/// on a single actor, and an internal serial task chain makes each -/// `updateMetadata` call run to completion (including its async hops) before the -/// next one starts, so the read-merge-write is atomic across callers. +/// To prevent that, all metadata map writes go through one shared instance of +/// this class. `@MainActor` keeps the bookkeeping on a single actor, and an +/// internal serial task chain makes each `updateMetadata` call run to completion +/// (including its async hops) before the next one starts, so the read-merge-write +/// is atomic across callers. @MainActor public final class ProfileMetadataWriter: ProfileMetadataWriterProtocol { - private let myProfileWriter: any MyProfileWriterProtocol + private let profilesRepository: @Sendable () -> ProfilesRepository private let databaseReader: any DatabaseReader /// Tail of the serial task chain. Each new call appends itself after the @@ -44,10 +48,10 @@ public final class ProfileMetadataWriter: ProfileMetadataWriterProtocol { private nonisolated(unsafe) var tail: Task = Task {} public nonisolated init( - myProfileWriter: any MyProfileWriterProtocol, + profilesRepository: @escaping @Sendable () -> ProfilesRepository, databaseReader: any DatabaseReader ) { - self.myProfileWriter = myProfileWriter + self.profilesRepository = profilesRepository self.databaseReader = databaseReader } @@ -57,22 +61,15 @@ public final class ProfileMetadataWriter: ProfileMetadataWriterProtocol { update: @escaping @Sendable (inout ProfileMetadata) -> Void ) async throws { let previous = tail - let work = Task { @MainActor [myProfileWriter, databaseReader] () -> Result in + let work = Task { @MainActor [profilesRepository, databaseReader] () -> Result in await previous.value do { let existing = try await databaseReader.read { db in - try DBMemberProfile.fetchOne( - db, - conversationId: conversationId, - inboxId: inboxId - )?.metadata + try DBMyProfile.filter(DBMyProfile.Columns.inboxId == inboxId).fetchOne(db)?.metadata } var merged: ProfileMetadata = existing ?? [:] update(&merged) - try await myProfileWriter.updateAndPublish( - metadata: merged.isEmpty ? nil : merged, - conversationId: conversationId - ) + try await profilesRepository().publishMyProfileMetadata(merged.isEmpty ? nil : merged) return .success(()) } catch { return .failure(error) diff --git a/ConvosCore/Sources/ConvosCore/Storage/Writers/ProfileSyncCoordinator.swift b/ConvosCore/Sources/ConvosCore/Storage/Writers/ProfileSyncCoordinator.swift deleted file mode 100644 index 05d350b9f..000000000 --- a/ConvosCore/Sources/ConvosCore/Storage/Writers/ProfileSyncCoordinator.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -/// Serializes activate-sync work per `conversationId` so two `.ready` transitions for the -/// same conversation can't race and double-upload. Each new request waits for the prior -/// task on the same conversationId to complete before running, picking up the latest -/// global-profile state on the second pass. -public actor ProfileSyncCoordinator { - public static let shared: ProfileSyncCoordinator = .init() - - private var pending: [String: Task] = [:] - - public func run(conversationId: String, _ work: @escaping @Sendable () async -> Void) { - let previous = pending[conversationId] - let token = UUID() - let task = Task { [weak self, previous] in - await previous?.value - await work() - await self?.clear(conversationId: conversationId, token: token) - } - pending[conversationId] = task - tokens[conversationId] = token - } - - private var tokens: [String: UUID] = [:] - - private func clear(conversationId: String, token: UUID) { - if tokens[conversationId] == token { - pending[conversationId] = nil - tokens[conversationId] = nil - } - } -} diff --git a/ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift b/ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift index 200e26bf6..2d144898d 100644 --- a/ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift +++ b/ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift @@ -299,7 +299,7 @@ actor StreamProcessor: StreamProcessorProtocol { } guard explodeSettings == nil else { return } - if await processProfileMessage(message, conversationId: conversation.id) { + if await processProfileMessage(message, conversationId: conversation.id, currentInboxId: params.client.inboxId) { return } @@ -467,7 +467,7 @@ actor StreamProcessor: StreamProcessorProtocol { return true } - private func processProfileUpdate(_ message: DecodedMessage, conversationId: String) async { + private func processProfileUpdate(_ message: DecodedMessage, conversationId: String, currentInboxId: String) async { guard let update = try? ProfileUpdateCodec().decode(content: message.encodedContent) else { Log.warning("Failed to decode ProfileUpdate from message \(message.id)") return @@ -481,60 +481,22 @@ actor StreamProcessor: StreamProcessorProtocol { } do { try await databaseWriter.write { db in - let member = DBMember(inboxId: senderInboxId) - try member.save(db) - - var profile = try DBMemberProfile.fetchOne( - db, - conversationId: conversationId, - inboxId: senderInboxId - ) ?? DBMemberProfile( + let metadata = update.profileMetadata + try ProfileInboundApplier.apply( + db: db, conversationId: conversationId, - inboxId: senderInboxId, - name: nil, - avatar: nil + event: ProfileInboundApplier.Incoming( + inboxId: senderInboxId, + source: .profileUpdate, + name: update.hasName ? update.name : nil, + avatar: .addressed(update.hasEncryptedImage ? update.encryptedImage : nil), + memberKind: update.memberKind.dbMemberKind, + metadata: metadata.isEmpty ? nil : metadata, + receivedAt: receivedAt + ), + selfInboxId: currentInboxId, + fallbackEncryptionKey: try DBConversation.fetchOne(db, id: conversationId)?.imageEncryptionKey ) - - // Never clear an existing name with a name-less/blank update - // (see DBMemberProfile.withInboundName). - profile = profile.withInboundName(update.hasName ? update.name : nil) - - if update.hasEncryptedImage, update.encryptedImage.isValid { - let encryptionKey: Data? = if let existingKey = profile.avatarKey { - existingKey - } else { - try DBConversation.fetchOne(db, id: conversationId)?.imageEncryptionKey - } - profile = profile.with( - avatar: update.encryptedImage.url, - salt: update.encryptedImage.salt, - nonce: update.encryptedImage.nonce, - key: encryptionKey - ) - } else { - profile = profile.with(avatar: nil, salt: nil, nonce: nil, key: nil) - } - - let priorMemberKind = profile.memberKind - profile = profile.with(memberKind: update.memberKind.dbMemberKind) - - let profileMetadata = update.profileMetadata - profile = profile.with(metadata: profileMetadata.isEmpty ? nil : profileMetadata) - - if profile.isAgent { - let verification = profile.hydrateProfile().verifyCachedAgentAttestation() - if verification.isVerified { - profile = profile.with(memberKind: DBMemberKind.from(agentVerification: verification)) - } - } - - if let priorMemberKind, priorMemberKind.agentVerification.isVerified, - !profile.agentVerification.isVerified { - profile = profile.with(memberKind: priorMemberKind) - } - - try ContactsWriter.saveMemberProfileAndMirrorToContactInTransaction(db: db, profile: profile, receivedAt: receivedAt) - try Self.markConversationHasVerifiedAgentIfNeeded(profile: profile, conversationId: conversationId, db: db) } Log.debug("Processed ProfileUpdate from \(senderInboxId) in \(conversationId)") } catch { @@ -542,7 +504,7 @@ actor StreamProcessor: StreamProcessorProtocol { } } - private func processProfileSnapshot(_ message: DecodedMessage, conversationId: String) async { + private func processProfileSnapshot(_ message: DecodedMessage, conversationId: String, currentInboxId: String) async { guard let snapshot = try? ProfileSnapshotCodec().decode(content: message.encodedContent) else { Log.warning("Failed to decode ProfileSnapshot from message \(message.id)") return @@ -553,63 +515,26 @@ actor StreamProcessor: StreamProcessorProtocol { do { try await databaseWriter.write { db in - let encryptionKey = try DBConversation.fetchOne(db, id: conversationId)?.imageEncryptionKey - + let fallbackKey = try DBConversation.fetchOne(db, id: conversationId)?.imageEncryptionKey for memberProfile in snapshot.profiles { let inboxId = memberProfile.inboxIdString guard !inboxId.isEmpty else { continue } - - let member = DBMember(inboxId: inboxId) - try member.save(db) - - let existingProfile = try DBMemberProfile.fetchOne( - db, + let metadata = memberProfile.profileMetadata + try ProfileInboundApplier.apply( + db: db, conversationId: conversationId, - inboxId: inboxId + event: ProfileInboundApplier.Incoming( + inboxId: inboxId, + source: .profileSnapshot, + name: memberProfile.hasName ? memberProfile.name : nil, + avatar: .fillIfPresent(memberProfile.hasEncryptedImage ? memberProfile.encryptedImage : nil), + memberKind: memberProfile.memberKind.dbMemberKind, + metadata: metadata.isEmpty ? nil : metadata, + receivedAt: receivedAt + ), + selfInboxId: currentInboxId, + fallbackEncryptionKey: fallbackKey ) - - if existingProfile?.name != nil || existingProfile?.avatar != nil { - continue - } - - var profile = existingProfile ?? DBMemberProfile( - conversationId: conversationId, - inboxId: inboxId, - name: nil, - avatar: nil - ) - - profile = profile.with(name: memberProfile.hasName ? memberProfile.name : nil) - - if memberProfile.hasEncryptedImage, memberProfile.encryptedImage.isValid { - profile = profile.with( - avatar: memberProfile.encryptedImage.url, - salt: memberProfile.encryptedImage.salt, - nonce: memberProfile.encryptedImage.nonce, - key: existingProfile?.avatarKey ?? encryptionKey - ) - } - - let priorMemberKind = profile.memberKind - profile = profile.with(memberKind: memberProfile.memberKind.dbMemberKind) - - let snapshotMetadata = memberProfile.profileMetadata - profile = profile.with(metadata: snapshotMetadata.isEmpty ? nil : snapshotMetadata) - - if profile.isAgent { - let verification = profile.hydrateProfile().verifyCachedAgentAttestation() - if verification.isVerified { - profile = profile.with(memberKind: DBMemberKind.from(agentVerification: verification)) - } - } - - if let priorMemberKind, priorMemberKind.agentVerification.isVerified, - !profile.agentVerification.isVerified { - profile = profile.with(memberKind: priorMemberKind) - } - - try ContactsWriter.saveMemberProfileAndMirrorToContactInTransaction(db: db, profile: profile, receivedAt: receivedAt) - try Self.markConversationHasVerifiedAgentIfNeeded(profile: profile, conversationId: conversationId, db: db) } } Log.debug("Processed ProfileSnapshot with \(snapshot.profiles.count) profiles in \(conversationId)") @@ -618,17 +543,6 @@ actor StreamProcessor: StreamProcessorProtocol { } } - private static func markConversationHasVerifiedAgentIfNeeded( - profile: DBMemberProfile, - conversationId: String, - db: Database - ) throws { - guard profile.agentVerification.isConvosAgent, - let conversation = try DBConversation.fetchOne(db, id: conversationId), - !conversation.hasHadVerifiedAgent else { return } - try conversation.with(hasHadVerifiedAgent: true).save(db) - } - private func sendInitialProfileSnapshot(group: XMTPiOS.Group) async { do { try await ProfileSnapshotBuilder.sendSnapshot( @@ -675,13 +589,13 @@ actor StreamProcessor: StreamProcessorProtocol { private func getSenderDisplayName(senderInboxId: String, conversationId: String) async -> String { do { // The contact's display name (the user's global profile snapshot - // for this inbox) wins over the per-conversation profile name, + // for this inbox) wins over the canonical profile name, // mirroring `Profile.formattedNamesString(memberNameOverride:)`. let name: String? = try await databaseReader.read { db in if let contactName = try ContactsRepository.contactNameInTransaction(db: db, inboxId: senderInboxId) { return contactName } - return try DBMemberProfile.fetchOne(db, conversationId: conversationId, inboxId: senderInboxId)?.name + return try DBProfile.fetchOne(db, inboxId: senderInboxId)?.name } if let name, !name.isEmpty { return name @@ -919,15 +833,15 @@ extension StreamProcessor { /// Routes a profile message (update or snapshot) to its handler. Returns /// `true` when handled, so the caller stops further routing -- profiles are /// applied by the profile handlers, never stored as chat rows. - private func processProfileMessage(_ message: DecodedMessage, conversationId: String) async -> Bool { + private func processProfileMessage(_ message: DecodedMessage, conversationId: String, currentInboxId: String) async -> Bool { guard let contentType = try? message.encodedContent.type else { return false } if contentType == ContentTypeProfileUpdate { - await processProfileUpdate(message, conversationId: conversationId) + await processProfileUpdate(message, conversationId: conversationId, currentInboxId: currentInboxId) return true } else if contentType == ContentTypeProfileSnapshot { - await processProfileSnapshot(message, conversationId: conversationId) + await processProfileSnapshot(message, conversationId: conversationId, currentInboxId: currentInboxId) return true } return false diff --git a/ConvosCore/Tests/ConvosCoreTests/AgentBuilderConnectionGrantReplayerTests.swift b/ConvosCore/Tests/ConvosCoreTests/AgentBuilderConnectionGrantReplayerTests.swift new file mode 100644 index 000000000..a8009828a --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/AgentBuilderConnectionGrantReplayerTests.swift @@ -0,0 +1,91 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Guards that verified-agent discovery reads canonical `DBProfile` (joined to +/// the roster) rather than the legacy per-conversation `DBMemberProfile`, so an +/// agent learned only from a streamed profile message is still found. +@Suite("AgentBuilderConnectionGrantReplayer verified-agent discovery", .serialized) +struct AgentBuilderConnectionGrantReplayerTests { + private func makeDatabase() throws -> DatabaseQueue { + let dbQueue = try DatabaseQueue() + try SharedDatabaseMigrator.shared.migrate(database: dbQueue) + return dbQueue + } + + private func seedMember(_ db: Database, conversationId: String, inboxId: String) throws { + try DBMember(inboxId: inboxId).save(db, onConflict: .ignore) + try DBConversationMember( + conversationId: conversationId, + inboxId: inboxId, + role: .member, + consent: .allowed, + createdAt: Date(), + invitedByInboxId: nil + ).save(db) + } + + private func seedConversation(_ db: Database, id: String) throws { + try DBConversation( + id: id, + clientConversationId: id, + inviteTag: "tag-\(id)", + creatorId: "self", + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: true, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + conversationEmoji: nil, + imageLastRenewed: nil, + isUnused: false, + hasHadVerifiedAgent: false + ).insert(db) + } + + @Test("returns verified agents from canonical DBProfile, skipping humans") + func findsVerifiedAgentFromCanonical() throws { + let db = try makeDatabase() + let conversationId = "c1" + try db.write { db in + try seedConversation(db, id: conversationId) + try seedMember(db, conversationId: conversationId, inboxId: "agent") + try seedMember(db, conversationId: conversationId, inboxId: "human") + // Verified agent identity in canonical `profile`, no `memberProfile`. + try DBProfile(inboxId: "agent", memberKind: .verifiedConvos, profileSource: .profileUpdate, updatedAt: Date()).save(db) + try DBProfile(inboxId: "human", profileSource: .profileUpdate, updatedAt: Date()).save(db) + } + + let ids = try db.read { db in + try AgentBuilderConnectionGrantReplayer.verifiedAgentInboxIds(db: db, conversationId: conversationId) + } + #expect(ids == ["agent"]) + } + + @Test("excludes an unverified agent") + func excludesUnverifiedAgent() throws { + let db = try makeDatabase() + let conversationId = "c1" + try db.write { db in + try seedConversation(db, id: conversationId) + try seedMember(db, conversationId: conversationId, inboxId: "agent") + // A plain (unverified) agent kind should not be returned. + try DBProfile(inboxId: "agent", memberKind: .agent, profileSource: .profileUpdate, updatedAt: Date()).save(db) + } + + let ids = try db.read { db in + try AgentBuilderConnectionGrantReplayer.verifiedAgentInboxIds(db: db, conversationId: conversationId) + } + #expect(ids.isEmpty) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/AgentTemplateCacheCoordinatorTests.swift b/ConvosCore/Tests/ConvosCoreTests/AgentTemplateCacheCoordinatorTests.swift index 784ac7186..b4ae96449 100644 --- a/ConvosCore/Tests/ConvosCoreTests/AgentTemplateCacheCoordinatorTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/AgentTemplateCacheCoordinatorTests.swift @@ -70,7 +70,11 @@ struct AgentTemplateCacheCoordinatorTests { coordinator.start() - let fetchedFirst = await waitUntil(.seconds(5)) { apiClient.fetchedIds.contains("tmpl-1") } + // Generous timeout: the coordinator observes on a detached task, which + // the cooperative pool can starve for several seconds in the integration + // job (it runs alongside the network-bound suite). Mirrors the headroom + // in AgentTemplateRepositoryTests; it returns the instant the fetch lands. + let fetchedFirst = await waitUntil(.seconds(20)) { apiClient.fetchedIds.contains("tmpl-1") } #expect(fetchedFirst, "start() should fetch the uncached template") coordinator.stop() diff --git a/ConvosCore/Tests/ConvosCoreTests/Assets/AssetRenewalManagerTests.swift b/ConvosCore/Tests/ConvosCoreTests/Assets/AssetRenewalManagerTests.swift index 9a3d08852..cb305341e 100644 --- a/ConvosCore/Tests/ConvosCoreTests/Assets/AssetRenewalManagerTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/Assets/AssetRenewalManagerTests.swift @@ -5,22 +5,17 @@ import Testing @Suite("AssetRenewalManager Tests", .serialized) struct AssetRenewalManagerTests { + private let avatarURL = "https://example.com/avatar.bin" + @Test("performRenewalIfNeeded skips when no stale assets") func testSkipsWhenNoStaleAssets() async throws { let fixtures = try await makeTestFixtures() let recentDate = Date() try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: recentDate - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: recentDate) } await fixtures.manager.performRenewalIfNeeded() @@ -34,16 +29,9 @@ struct AssetRenewalManagerTests { let oldDate = Date().addingTimeInterval(-20 * 24 * 60 * 60) try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: oldDate - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: oldDate) } await fixtures.manager.performRenewalIfNeeded() @@ -56,16 +44,9 @@ struct AssetRenewalManagerTests { let fixtures = try await makeTestFixtures() try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: nil - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: nil) } await fixtures.manager.performRenewalIfNeeded() @@ -79,16 +60,9 @@ struct AssetRenewalManagerTests { let recentDate = Date() try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: recentDate - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: recentDate) } _ = await fixtures.manager.forceRenewal() @@ -102,20 +76,13 @@ struct AssetRenewalManagerTests { fixtures.mockAPI.renewResult = AssetRenewalResult(renewed: 1, failed: 0, expiredKeys: []) try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: nil - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: nil) } let asset = RenewableAsset.profileAvatar( - url: "https://example.com/avatar.bin", + url: avatarURL, conversationId: "convo-1", inboxId: "inbox-1", lastRenewed: nil @@ -125,10 +92,10 @@ struct AssetRenewalManagerTests { #expect(result?.renewed == 1) - let profile = try await fixtures.dbWriter.read { db in - try DBMemberProfile.fetchOne(db, conversationId: "convo-1", inboxId: "inbox-1") + let avatar = try await fixtures.dbWriter.read { db in + try DBProfileAvatar.fetchOne(db, inboxId: "inbox-1", conversationId: "convo-1") } - #expect(profile?.avatarLastRenewed != nil) + #expect(avatar?.lastRenewed != nil) } @Test("renewSingleAsset does not record timestamp on failure") @@ -137,20 +104,13 @@ struct AssetRenewalManagerTests { fixtures.mockAPI.renewResult = AssetRenewalResult(renewed: 0, failed: 1, expiredKeys: []) try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: nil - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: nil) } let asset = RenewableAsset.profileAvatar( - url: "https://example.com/avatar.bin", + url: avatarURL, conversationId: "convo-1", inboxId: "inbox-1", lastRenewed: nil @@ -158,10 +118,10 @@ struct AssetRenewalManagerTests { _ = await fixtures.manager.renewSingleAsset(asset) - let profile = try await fixtures.dbWriter.read { db in - try DBMemberProfile.fetchOne(db, conversationId: "convo-1", inboxId: "inbox-1") + let avatar = try await fixtures.dbWriter.read { db in + try DBProfileAvatar.fetchOne(db, inboxId: "inbox-1", conversationId: "convo-1") } - #expect(profile?.avatarLastRenewed == nil) + #expect(avatar?.lastRenewed == nil) } @Test("renewSingleAsset handles expired asset by clearing URL") @@ -170,19 +130,13 @@ struct AssetRenewalManagerTests { fixtures.mockAPI.renewResult = AssetRenewalResult(renewed: 0, failed: 1, expiredKeys: ["avatar.bin"]) try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin" - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: nil) } let asset = RenewableAsset.profileAvatar( - url: "https://example.com/avatar.bin", + url: avatarURL, conversationId: "convo-1", inboxId: "inbox-1", lastRenewed: nil @@ -192,10 +146,10 @@ struct AssetRenewalManagerTests { #expect(result?.expiredKeys.contains("avatar.bin") == true) - let profile = try await fixtures.dbWriter.read { db in - try DBMemberProfile.fetchOne(db, conversationId: "convo-1", inboxId: "inbox-1") + let avatar = try await fixtures.dbWriter.read { db in + try DBProfileAvatar.fetchOne(db, inboxId: "inbox-1", conversationId: "convo-1") } - #expect(profile?.avatar == nil) + #expect(avatar?.url == nil) } @Test("renewSingleAsset returns nil for asset without key") @@ -216,8 +170,7 @@ struct AssetRenewalManagerTests { fixtures.mockAPI.renewResult = AssetRenewalResult(renewed: 2, failed: 0, expiredKeys: []) try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) try makeDBConversation( id: "convo-2", @@ -226,24 +179,18 @@ struct AssetRenewalManagerTests { kind: .group, imageURL: "https://example.com/group.bin" ).insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: nil - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: nil) } _ = await fixtures.manager.forceRenewal() - let profile = try await fixtures.dbWriter.read { db in - try DBMemberProfile.fetchOne(db, conversationId: "convo-1", inboxId: "inbox-1") + let avatar = try await fixtures.dbWriter.read { db in + try DBProfileAvatar.fetchOne(db, inboxId: "inbox-1", conversationId: "convo-1") } let conversation = try await fixtures.dbWriter.read { db in try DBConversation.fetchOne(db, key: "convo-2") } - #expect(profile?.avatarLastRenewed != nil) + #expect(avatar?.lastRenewed != nil) #expect(conversation?.imageLastRenewed != nil) } @@ -253,8 +200,7 @@ struct AssetRenewalManagerTests { fixtures.mockAPI.renewResult = AssetRenewalResult(renewed: 1, failed: 1, expiredKeys: ["avatar.bin"]) try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) try makeDBConversation( id: "convo-2", @@ -263,24 +209,18 @@ struct AssetRenewalManagerTests { kind: .group, imageURL: "https://example.com/group.bin" ).insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: nil - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: nil) } _ = await fixtures.manager.forceRenewal() - let profile = try await fixtures.dbWriter.read { db in - try DBMemberProfile.fetchOne(db, conversationId: "convo-1", inboxId: "inbox-1") + let avatar = try await fixtures.dbWriter.read { db in + try DBProfileAvatar.fetchOne(db, inboxId: "inbox-1", conversationId: "convo-1") } let conversation = try await fixtures.dbWriter.read { db in try DBConversation.fetchOne(db, key: "convo-2") } - #expect(profile?.avatarLastRenewed == nil) + #expect(avatar?.lastRenewed == nil) #expect(conversation?.imageLastRenewed != nil) } @@ -292,16 +232,9 @@ struct AssetRenewalManagerTests { } try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: nil - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: nil) } let result = await fixtures.manager.forceRenewal() @@ -311,53 +244,34 @@ struct AssetRenewalManagerTests { #expect(result?.renewed == 0) } - @Test("Records renewal for all profiles with same URL") + @Test("Records renewal for all avatar slots with same URL") func testRecordsRenewalForAllProfilesWithSameURL() async throws { let fixtures = try await makeTestFixtures() fixtures.mockAPI.renewResult = AssetRenewalResult(renewed: 1, failed: 0, expiredKeys: []) let sharedAvatarURL = "https://example.com/shared-avatar.bin" try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) try makeDBConversation(id: "convo-2", inboxId: "inbox-1", clientId: "client-1").insert(db) try makeDBConversation(id: "convo-3", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Me in convo 1", - avatar: sharedAvatarURL, - avatarLastRenewed: nil - ).insert(db) - try DBMemberProfile( - conversationId: "convo-2", - inboxId: "inbox-1", - name: "Me in convo 2", - avatar: sharedAvatarURL, - avatarLastRenewed: nil - ).insert(db) - try DBMemberProfile( - conversationId: "convo-3", - inboxId: "inbox-1", - name: "Me in convo 3", - avatar: sharedAvatarURL, - avatarLastRenewed: nil - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: sharedAvatarURL, lastRenewed: nil) + try seedAvatar(db, conversationId: "convo-2", url: sharedAvatarURL, lastRenewed: nil) + try seedAvatar(db, conversationId: "convo-3", url: sharedAvatarURL, lastRenewed: nil) } _ = await fixtures.manager.forceRenewal() #expect(fixtures.mockAPI.renewCallCount == 1) - let profiles = try await fixtures.dbWriter.read { db in - try DBMemberProfile - .filter(DBMemberProfile.Columns.avatar == sharedAvatarURL) + let avatars = try await fixtures.dbWriter.read { db in + try DBProfileAvatar + .filter(DBProfileAvatar.Columns.url == sharedAvatarURL) .fetchAll(db) } - #expect(profiles.count == 3) - for profile in profiles { - #expect(profile.avatarLastRenewed != nil) + #expect(avatars.count == 3) + for avatar in avatars { + #expect(avatar.lastRenewed != nil) } } @@ -367,16 +281,9 @@ struct AssetRenewalManagerTests { fixtures.mockAPI.renewResult = AssetRenewalResult(renewed: 1, failed: 0, expiredKeys: []) try await fixtures.dbWriter.write { db in - try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) - try DBMember(inboxId: "inbox-1").insert(db) + try seedInbox(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", - inboxId: "inbox-1", - name: "Test", - avatar: "https://example.com/avatar.bin", - avatarLastRenewed: nil - ).insert(db) + try seedAvatar(db, conversationId: "convo-1", url: avatarURL, lastRenewed: nil) } async let renewal1: Void = fixtures.manager.performRenewalIfNeeded() @@ -415,6 +322,22 @@ private extension AssetRenewalManagerTests { ) } + func seedInbox(_ db: Database) throws { + try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) + try DBMember(inboxId: "inbox-1").insert(db) + } + + func seedAvatar(_ db: Database, conversationId: String, url: String, lastRenewed: Date?) throws { + try DBProfileAvatar( + inboxId: "inbox-1", + conversationId: conversationId, + url: url, + profileSource: .profileUpdate, + updatedAt: Date(), + lastRenewed: lastRenewed + ).insert(db) + } + func makeDBConversation( id: String, inboxId: String, diff --git a/ConvosCore/Tests/ConvosCoreTests/Assets/AssetRenewalURLCollectorTests.swift b/ConvosCore/Tests/ConvosCoreTests/Assets/AssetRenewalURLCollectorTests.swift index 83a820a47..ca0d62ee0 100644 --- a/ConvosCore/Tests/ConvosCoreTests/Assets/AssetRenewalURLCollectorTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/Assets/AssetRenewalURLCollectorTests.swift @@ -14,11 +14,12 @@ struct AssetRenewalURLCollectorTests { try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) try DBMember(inboxId: "inbox-1").insert(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", + try DBProfileAvatar( inboxId: "inbox-1", - name: "Test User", - avatar: avatarURL + conversationId: "convo-1", + url: avatarURL, + profileSource: .profileUpdate, + updatedAt: Date() ).insert(db) } @@ -115,17 +116,19 @@ struct AssetRenewalURLCollectorTests { try DBMember(inboxId: "inbox-1").insert(db) try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) try makeDBConversation(id: "convo-2", inboxId: "inbox-1", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", + try DBProfileAvatar( inboxId: "inbox-1", - name: "User 1", - avatar: sharedURL + conversationId: "convo-1", + url: sharedURL, + profileSource: .profileUpdate, + updatedAt: Date() ).insert(db) - try DBMemberProfile( - conversationId: "convo-2", + try DBProfileAvatar( inboxId: "inbox-1", - name: "User 2", - avatar: sharedURL + conversationId: "convo-2", + url: sharedURL, + profileSource: .profileUpdate, + updatedAt: Date() ).insert(db) } @@ -156,17 +159,19 @@ struct AssetRenewalURLCollectorTests { try DBMember(inboxId: "my-inbox").insert(db) try DBMember(inboxId: "other-inbox").insert(db) try makeDBConversation(id: "convo-1", inboxId: "my-inbox", clientId: "client-1").insert(db) - try DBMemberProfile( - conversationId: "convo-1", + try DBProfileAvatar( inboxId: "my-inbox", - name: "Me", - avatar: myAvatarURL - ).insert(db) - try DBMemberProfile( conversationId: "convo-1", + url: myAvatarURL, + profileSource: .profileUpdate, + updatedAt: Date() + ).insert(db) + try DBProfileAvatar( inboxId: "other-inbox", - name: "Other", - avatar: otherAvatarURL + conversationId: "convo-1", + url: otherAvatarURL, + profileSource: .profileUpdate, + updatedAt: Date() ).insert(db) } @@ -182,6 +187,38 @@ struct AssetRenewalURLCollectorTests { } } + @Test("collectStaleAssets returns never-renewed avatars and excludes freshly renewed") + func testCollectStaleAssets() async throws { + let fixtures = try await makeTestFixtures() + let staleURL = "https://example.com/stale.bin" + let freshURL = "https://example.com/fresh.bin" + let now = Date() + let threshold = now.addingTimeInterval(-3600) + + try await fixtures.dbWriter.write { db in + try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: Date()).insert(db) + try DBMember(inboxId: "inbox-1").insert(db) + try makeDBConversation(id: "convo-1", inboxId: "inbox-1", clientId: "client-1").insert(db) + try makeDBConversation(id: "convo-2", inboxId: "inbox-1", clientId: "client-1").insert(db) + // Never renewed -> stale. + try DBProfileAvatar( + inboxId: "inbox-1", conversationId: "convo-1", url: staleURL, + profileSource: .profileUpdate, updatedAt: now, lastRenewed: nil + ).insert(db) + // Renewed after the threshold -> not stale. + try DBProfileAvatar( + inboxId: "inbox-1", conversationId: "convo-2", url: freshURL, + profileSource: .profileUpdate, updatedAt: now, lastRenewed: now + ).insert(db) + } + + let collector = AssetRenewalURLCollector(databaseReader: fixtures.dbReader) + let assets = try collector.collectStaleAssets(olderThan: threshold) + + #expect(assets.count == 1) + #expect(assets.first?.url == staleURL) + } + @Test("Extracts key from URL path") func testKeyExtraction() { let asset = RenewableAsset.profileAvatar( diff --git a/ConvosCore/Tests/ConvosCoreTests/ConnectionGrantWriterTests.swift b/ConvosCore/Tests/ConvosCoreTests/ConnectionGrantWriterTests.swift index b0d32d270..3a555d4c6 100644 --- a/ConvosCore/Tests/ConvosCoreTests/ConnectionGrantWriterTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/ConnectionGrantWriterTests.swift @@ -17,25 +17,22 @@ struct ConnectionGrantWriterTests { private struct Fixture { let databaseManager: MockDatabaseManager let sessionStateManager: MockSessionStateManager - let profileWriter: MockMyProfileWriter + let metadataWriter: MockProfileMetadataWriter let writer: CloudConnectionGrantWriter init(inboxId: String = "mock-inbox-id", apiClient: (any ConvosAPIClientProtocol)? = nil) { let databaseManager = MockDatabaseManager.makeTestDatabase() - let profileWriter = MockMyProfileWriter() + let metadataWriter = MockProfileMetadataWriter() let mockClient = MockXMTPClientProvider(inboxId: inboxId) let sessionStateManager = MockSessionStateManager(mockClient: mockClient, mockAPIClient: apiClient) self.databaseManager = databaseManager self.sessionStateManager = sessionStateManager - self.profileWriter = profileWriter + self.metadataWriter = metadataWriter self.writer = CloudConnectionGrantWriter( sessionStateManager: sessionStateManager, databaseWriter: databaseManager.dbWriter, databaseReader: databaseManager.dbReader, - profileMetadataWriter: ProfileMetadataWriter( - myProfileWriter: profileWriter, - databaseReader: databaseManager.dbReader - ), + profileMetadataWriter: metadataWriter, servicesStore: Self.makeServicesStore(apiClient: apiClient) ) } @@ -158,10 +155,10 @@ struct ConnectionGrantWriterTests { #expect(stored.first?.connectionId == connection.id) #expect(stored.first?.serviceId == connection.serviceId) - #expect(fixture.profileWriter.publishedMetadata.count == 1) - let published = try #require(fixture.profileWriter.publishedMetadata.first) + #expect(fixture.metadataWriter.updates.count == 1) + let published = try #require(fixture.metadataWriter.updates.first) #expect(published.conversationId == conversationId) - let metadata = try #require(published.metadata) + let metadata = published.metadata guard case .string(let grantsJson) = try #require(metadata["connections"]) else { Issue.record("connections entry was not a string") return @@ -182,7 +179,7 @@ struct ConnectionGrantWriterTests { try fixture.seedConversation(id: conversationId) struct PublishFailure: Error, Equatable {} - fixture.profileWriter.publishError = PublishFailure() + fixture.metadataWriter.updateError = PublishFailure() var caughtExpectedError: Bool = false do { @@ -207,7 +204,7 @@ struct ConnectionGrantWriterTests { await #expect(throws: CloudConnectionGrantError.self) { try await fixture.writer.grantConnection("missing", to: "conv_x", grantedToInboxId: "agent-1") } - #expect(fixture.profileWriter.publishedMetadata.isEmpty) + #expect(fixture.metadataWriter.updates.isEmpty) let stored = try fixture.storedGrants(for: "conv_x") #expect(stored.isEmpty) } @@ -222,7 +219,7 @@ struct ConnectionGrantWriterTests { await #expect(throws: CloudConnectionGrantError.self) { try await fixture.writer.grantConnection(connection.id, to: "conv_x", grantedToInboxId: "agent-1") } - #expect(fixture.profileWriter.publishedMetadata.isEmpty) + #expect(fixture.metadataWriter.updates.isEmpty) } // MARK: - Revoke flow @@ -247,11 +244,12 @@ struct ConnectionGrantWriterTests { #expect(stored.isEmpty) // The published metadata should have cleared the connections entry. - #expect(fixture.profileWriter.publishedMetadata.count == 1) - let published = try #require(fixture.profileWriter.publishedMetadata.first) + #expect(fixture.metadataWriter.updates.count == 1) + let published = try #require(fixture.metadataWriter.updates.first) #expect(published.conversationId == conversationId) - // With no remaining grants the writer passes nil metadata (empty map collapses). - #expect(published.metadata == nil) + // With no remaining grants the closure removes the connections key, so + // the merged map is empty (the writer collapses that to nil downstream). + #expect(published.metadata.isEmpty) } @Test("Revoke: publish failure leaves the DB row intact and propagates the error") @@ -269,7 +267,7 @@ struct ConnectionGrantWriterTests { ) struct PublishFailure: Error, Equatable {} - fixture.profileWriter.publishError = PublishFailure() + fixture.metadataWriter.updateError = PublishFailure() var caughtExpectedError: Bool = false do { @@ -294,7 +292,7 @@ struct ConnectionGrantWriterTests { try await fixture.writer.revokeGrant(connectionId: "nope", from: "conv_nope", grantedToInboxId: "agent-1") - #expect(fixture.profileWriter.publishedMetadata.isEmpty) + #expect(fixture.metadataWriter.updates.isEmpty) let stored = try fixture.storedGrants(for: "conv_nope") #expect(stored.isEmpty) } @@ -317,9 +315,9 @@ struct ConnectionGrantWriterTests { let stored = try fixture.storedGrants(for: conversationId) #expect(stored.count == 2) - #expect(fixture.profileWriter.publishedMetadata.count == 2) - let lastPublish = try #require(fixture.profileWriter.publishedMetadata.last) - let metadata = try #require(lastPublish.metadata) + #expect(fixture.metadataWriter.updates.count == 2) + let lastPublish = try #require(fixture.metadataWriter.updates.last) + let metadata = lastPublish.metadata guard case .string(let grantsJson) = try #require(metadata["connections"]) else { Issue.record("connections entry was not a string") return diff --git a/ConvosCore/Tests/ConvosCoreTests/ContactSyncCoordinatorTests.swift b/ConvosCore/Tests/ConvosCoreTests/ContactSyncCoordinatorTests.swift index 936f62455..deec5c15a 100644 --- a/ConvosCore/Tests/ConvosCoreTests/ContactSyncCoordinatorTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/ContactSyncCoordinatorTests.swift @@ -53,12 +53,21 @@ struct ContactSyncCoordinatorTests { ).save(db) if let profile = memberProfiles[inboxId] { - try DBMemberProfile( - conversationId: conversationId, + try DBProfile( inboxId: inboxId, name: profile.name, - avatar: profile.avatar + profileSource: .profileUpdate, + updatedAt: Date() ).save(db) + if let avatarURL = profile.avatar { + try DBProfileAvatar( + inboxId: inboxId, + conversationId: conversationId, + url: avatarURL, + profileSource: .profileUpdate, + updatedAt: Date() + ).save(db) + } } } } @@ -260,19 +269,19 @@ struct ContactSyncCoordinatorTests { creatorInboxId: selfInboxId, memberInboxIds: [selfInboxId, agentInboxId] ) - // Overwrite the agent's per-conversation profile with one that - // carries template metadata and a verified-Convos member kind. - try DBMemberProfile( - conversationId: conversationId, + // Give the agent a canonical identity carrying template metadata and + // a verified-Convos member kind. + try DBProfile( inboxId: agentInboxId, name: "Americano", - avatar: nil, memberKind: .verifiedConvos, metadata: [ "templateId": .string("tmpl-coffee"), "publishedUrl": .string("https://convos.org/t/coffee"), "emoji": .string("☕️") - ] + ], + profileSource: .profileUpdate, + updatedAt: Date() ).save(db) } diff --git a/ConvosCore/Tests/ConvosCoreTests/ConversationMemberSelfProfileTests.swift b/ConvosCore/Tests/ConvosCoreTests/ConversationMemberSelfProfileTests.swift new file mode 100644 index 000000000..aa4dae63e --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ConversationMemberSelfProfileTests.swift @@ -0,0 +1,128 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Coverage for the self-identity fallback in `DBConversationMemberProfileWithRole`. +/// The current user is excluded from the canonical `profile` table, so both the +/// member `profile` join and the `inviterProfile` join are nil for self. These +/// tests lock in that hydration falls back to the locally-authored `myProfile` +/// so the current user does not render as "Somebody" as a member or inviter. +@Suite("ConversationMember self-profile fallback", .serialized) +struct ConversationMemberSelfProfileTests { + private static let selfInboxId: String = "me" + private static let otherInboxId: String = "alice" + private static let conversationId: String = "c1" + + private func makeDatabase() throws -> DatabaseQueue { + let dbQueue = try DatabaseQueue() + try SharedDatabaseMigrator.shared.migrate(database: dbQueue) + return dbQueue + } + + private func seedConversation(_ db: Database) throws { + for inboxId in [Self.selfInboxId, Self.otherInboxId] { + try DBMember(inboxId: inboxId).save(db, onConflict: .ignore) + } + try DBInbox(inboxId: Self.selfInboxId, clientId: "client-me", createdAt: Date()).save(db, onConflict: .ignore) + try DBConversation( + id: Self.conversationId, + clientConversationId: Self.conversationId, + inviteTag: "tag", + creatorId: Self.selfInboxId, + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: true, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + conversationEmoji: nil, + imageLastRenewed: nil, + isUnused: false, + hasHadVerifiedAgent: false + ).insert(db) + } + + private func addMember(_ db: Database, inboxId: String, invitedBy: String? = nil) throws { + try DBConversationMember( + conversationId: Self.conversationId, + inboxId: inboxId, + role: inboxId == Self.selfInboxId ? .superAdmin : .member, + consent: .allowed, + createdAt: Date(), + invitedByInboxId: invitedBy + ).insert(db) + } + + @Test("self member falls back to the myProfile name instead of Somebody") + func selfMemberUsesSelfProfileName() async throws { + let db = try makeDatabase() + try await db.write { db in + try seedConversation(db) + try addMember(db, inboxId: Self.selfInboxId) + try addMember(db, inboxId: Self.otherInboxId) + // Alice has a canonical profile; self is excluded from `profile` and + // only exists in `myProfile`. + try DBProfile(inboxId: Self.otherInboxId, name: "Alice", profileSource: .profileUpdate, updatedAt: Date()).save(db) + try DBMyProfile(inboxId: Self.selfInboxId, name: "Me").save(db) + } + + let rows = try await db.read { db in + try DBConversationMemberProfileWithRole.fetchAll( + db, conversationId: Self.conversationId, inboxIds: [Self.selfInboxId, Self.otherInboxId] + ) + } + + let selfRow = try #require(rows.first { $0.inboxId == Self.selfInboxId }) + let aliceRow = try #require(rows.first { $0.inboxId == Self.otherInboxId }) + #expect(selfRow.hydratedProfile().displayName == "Me") + #expect(aliceRow.hydratedProfile().displayName == "Alice") + } + + @Test("inviter that is the current user resolves invitedBy from myProfile") + func inviterSelfResolvesInvitedBy() async throws { + let db = try makeDatabase() + try await db.write { db in + try seedConversation(db) + try addMember(db, inboxId: Self.selfInboxId) + try addMember(db, inboxId: Self.otherInboxId, invitedBy: Self.selfInboxId) + try DBProfile(inboxId: Self.otherInboxId, name: "Alice", profileSource: .profileUpdate, updatedAt: Date()).save(db) + try DBMyProfile(inboxId: Self.selfInboxId, name: "Me").save(db) + } + + let aliceRow = try await db.read { db in + try #require(try DBConversationMemberProfileWithRole.fetchOne( + db, conversationId: Self.conversationId, inboxId: Self.otherInboxId + )) + } + + let member = aliceRow.hydrateConversationMember(currentInboxId: Self.selfInboxId) + #expect(member.invitedBy?.inboxId == Self.selfInboxId) + } + + @Test("a non-self member with a canonical profile is unaffected by the self join") + func nonSelfMemberUnaffected() async throws { + let db = try makeDatabase() + try await db.write { db in + try seedConversation(db) + try addMember(db, inboxId: Self.otherInboxId) + try DBProfile(inboxId: Self.otherInboxId, name: "Alice", profileSource: .profileUpdate, updatedAt: Date()).save(db) + } + + let aliceRow = try await db.read { db in + try #require(try DBConversationMemberProfileWithRole.fetchOne( + db, conversationId: Self.conversationId, inboxId: Self.otherInboxId + )) + } + #expect(aliceRow.myProfile == nil) + #expect(aliceRow.hydratedProfile().displayName == "Alice") + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ConversationWriterAppDataProfileTests.swift b/ConvosCore/Tests/ConvosCoreTests/ConversationWriterAppDataProfileTests.swift new file mode 100644 index 000000000..c738e43b3 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ConversationWriterAppDataProfileTests.swift @@ -0,0 +1,112 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Coverage for `ConversationWriter.applyAppDataProfile`: app-data-sourced member +/// profiles are merged into the canonical `profile` / `profileAvatar` tables at +/// the `.appData` source, so a member known only from group app-data renders +/// instead of showing as "Somebody" - without ever overriding a higher-source +/// value. +@Suite("ConversationWriter app-data profile fill") +struct ConversationWriterAppDataProfileTests { + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + + @Test("fills canonical identity and avatar when empty") + func fillsWhenEmpty() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + try await queue.write { db in + try ConversationWriter.applyAppDataProfile( + db: db, + conversationId: "c1", + profile: DBMemberProfile( + conversationId: "c1", inboxId: "alice", name: "Alice", + avatar: "u", avatarSalt: salt, avatarNonce: nonce, avatarKey: key + ), + selfInboxId: "me" + ) + } + + let identity = try await queue.read { db in try DBProfile.fetchOne(db, inboxId: "alice") } + let avatar = try await queue.read { db in try DBProfileAvatar.fetchOne(db, inboxId: "alice", conversationId: "c1") } + #expect(identity?.name == "Alice") + #expect(identity?.profileSource == .appData) + #expect(avatar?.url == "u") + #expect(avatar?.profileSource == .appData) + } + + @Test("does not override a subject-authored profileUpdate name") + func doesNotOverrideUpdate() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + try await queue.write { db in + try DBProfile(inboxId: "alice", name: "Real", profileSource: .profileUpdate, updatedAt: Date(timeIntervalSince1970: 100)).save(db) + try ConversationWriter.applyAppDataProfile( + db: db, + conversationId: "c1", + profile: DBMemberProfile(conversationId: "c1", inboxId: "alice", name: "AppData", avatar: nil), + selfInboxId: "me" + ) + } + + let identity = try await queue.read { db in try DBProfile.fetchOne(db, inboxId: "alice") } + #expect(identity?.name == "Real") + #expect(identity?.profileSource == .profileUpdate) + } + + @Test("fills a blank name left by a higher source, keeping that source") + func fillsBlankFromHigherSource() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + try await queue.write { db in + try DBProfile(inboxId: "alice", name: nil, profileSource: .profileUpdate, updatedAt: Date(timeIntervalSince1970: 100)).save(db) + try ConversationWriter.applyAppDataProfile( + db: db, + conversationId: "c1", + profile: DBMemberProfile(conversationId: "c1", inboxId: "alice", name: "AppData", avatar: nil), + selfInboxId: "me" + ) + } + + let identity = try await queue.read { db in try DBProfile.fetchOne(db, inboxId: "alice") } + #expect(identity?.name == "AppData") + // Provenance stays with the higher source; only the blank field was filled. + #expect(identity?.profileSource == .profileUpdate) + } + + @Test("skips the current user") + func skipsSelf() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + try await queue.write { db in + try ConversationWriter.applyAppDataProfile( + db: db, + conversationId: "c1", + profile: DBMemberProfile(conversationId: "c1", inboxId: "me", name: "Me", avatar: nil), + selfInboxId: "me" + ) + } + + let identity = try await queue.read { db in try DBProfile.fetchOne(db, inboxId: "me") } + #expect(identity == nil) + } + + @Test("without a valid image does not clear an existing avatar") + func doesNotClearAvatar() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + try await queue.write { db in + try DBProfileAvatar( + inboxId: "alice", conversationId: "c1", url: "old", salt: salt, nonce: nonce, + encryptionKey: key, profileSource: .profileUpdate, updatedAt: Date(timeIntervalSince1970: 100) + ).save(db) + try ConversationWriter.applyAppDataProfile( + db: db, + conversationId: "c1", + profile: DBMemberProfile(conversationId: "c1", inboxId: "alice", name: "Alice", avatar: nil), + selfInboxId: "me" + ) + } + + let avatar = try await queue.read { db in try DBProfileAvatar.fetchOne(db, inboxId: "alice", conversationId: "c1") } + #expect(avatar?.url == "old") + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/DBMemberProfileWithHelpersTests.swift b/ConvosCore/Tests/ConvosCoreTests/DBMemberProfileWithHelpersTests.swift index 023591953..5b3b9f060 100644 --- a/ConvosCore/Tests/ConvosCoreTests/DBMemberProfileWithHelpersTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/DBMemberProfileWithHelpersTests.swift @@ -2,11 +2,10 @@ import Foundation import Testing -/// Pins the invariant that every `with(…)` helper on `DBMemberProfile` preserves -/// the per-conversation `metadata` field. `MyProfileWriter.syncFromGlobalProfile` -/// only ever rebuilds member rows through these helpers, so as long as each one -/// copies metadata forward, activate-sync cannot wipe per-conversation metadata -/// when the global profile is updated. +/// Pins the invariant that every `with(...)` helper on `DBMemberProfile` +/// preserves the per-conversation `metadata` field. Inbound merge paths rebuild +/// member rows through these helpers, so as long as each one copies metadata +/// forward, an identity update cannot wipe per-conversation metadata. @Suite("DBMemberProfile.with(...) preserves metadata") struct DBMemberProfileWithHelpersTests { private static let baseMetadata: ProfileMetadata = [ diff --git a/ConvosCore/Tests/ConvosCoreTests/DeleteAllDataCleanSlateTests.swift b/ConvosCore/Tests/ConvosCoreTests/DeleteAllDataCleanSlateTests.swift new file mode 100644 index 000000000..3587ede6b --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/DeleteAllDataCleanSlateTests.swift @@ -0,0 +1,85 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Guards that "Delete All Data" leaves a true clean slate: every account-scoped +/// table, including the canonical profile tables, is empty afterward. A residual +/// row here means a re-paired or different account inherits stale identity data, +/// which also poisons the clean-slate assumption other manual repros rely on. +@Suite("Delete all data clean slate") +struct DeleteAllDataCleanSlateTests { + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + + @Test("wipeAccountScopedRows clears the canonical profile tables") + func clearsCanonicalProfileTables() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + + try await dbManager.dbWriter.write { db in + try DBMember(inboxId: "me").save(db, onConflict: .ignore) + try DBConversation( + id: "c1", + clientConversationId: "c1", + inviteTag: "tag-c1", + creatorId: "me", + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: true, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + conversationEmoji: nil, + imageLastRenewed: nil, + isUnused: false, + hasHadVerifiedAgent: false + ).insert(db) + try DBMyProfile(inboxId: "me", name: "Me").save(db) + try DBProfile( + inboxId: "alice", name: "Alice", profileSource: .profileUpdate, + updatedAt: Date(timeIntervalSince1970: 1) + ).save(db) + try DBProfileAvatar( + inboxId: "alice", conversationId: "c1", url: "u", salt: salt, nonce: nonce, + encryptionKey: key, profileSource: .profileUpdate, updatedAt: Date(timeIntervalSince1970: 1) + ).save(db) + try DBProfileAvatarSource( + inboxId: "me", plaintext: Data(repeating: 9, count: 8), version: 1, + updatedAt: Date(timeIntervalSince1970: 1) + ).save(db) + try DBProfilePublishJob( + id: "job1", seq: 1, conversationId: "c1", sourceVersion: 1, hasAvatar: true, + nextAttemptAt: Date(timeIntervalSince1970: 1), createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 1) + ).save(db) + } + + try await dbManager.dbWriter.write { db in + try SessionManager.wipeAccountScopedRows(db) + } + + let counts = try await dbManager.dbWriter.read { db in + ( + myProfile: try DBMyProfile.fetchCount(db), + profile: try DBProfile.fetchCount(db), + avatar: try DBProfileAvatar.fetchCount(db), + avatarSource: try DBProfileAvatarSource.fetchCount(db), + publishJob: try DBProfilePublishJob.fetchCount(db) + ) + } + #expect(counts.myProfile == 0) + #expect(counts.profile == 0) + #expect(counts.avatar == 0) + #expect(counts.avatarSource == 0) + #expect(counts.publishJob == 0) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/Integration/ProfileMessageIntegrationTests.swift b/ConvosCore/Tests/ConvosCoreTests/Integration/ProfileMessageIntegrationTests.swift index 9b79b260c..1b197b0cf 100644 --- a/ConvosCore/Tests/ConvosCoreTests/Integration/ProfileMessageIntegrationTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/Integration/ProfileMessageIntegrationTests.swift @@ -302,31 +302,17 @@ struct ProfileMessageIntegrationTests { _ = try await groupA.addMembers(inboxIds: [clientC.inboxID]) - let minimalDB = try DatabaseQueue() + // The snapshot builder now reads the canonical `profile` tables, so the + // "DB-only" agent lives in `DBProfile`, not the legacy `memberProfile`. + let minimalDB = try ProfileStoreTestSupport.makeQueue(conversations: [groupA.id]) try await minimalDB.write { db in - try db.create(table: "memberProfile") { t in - t.column("conversationId", .text).notNull() - t.column("inboxId", .text).notNull() - t.column("name", .text) - t.column("avatar", .text) - t.column("avatarSalt", .blob) - t.column("avatarNonce", .blob) - t.column("avatarKey", .blob) - t.column("avatarLastRenewed", .datetime) - t.column("memberKind", .text) - t.column("metadata", .jsonText) - t.column("imageSourceAssetIdentifier", .text) - t.column("imageSourceContentDigest", .text) - t.primaryKey(["conversationId", "inboxId"]) - } - let agentRow = DBMemberProfile( - conversationId: groupA.id, + try DBProfile( inboxId: clientB.inboxID, name: "Agent Bob", - avatar: nil, - memberKind: .agent - ) - try agentRow.insert(db) + memberKind: .agent, + profileSource: .profileUpdate, + updatedAt: Date() + ).save(db) } try await ProfileSnapshotBuilder.sendSnapshot( diff --git a/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryBenchmarkTests.swift b/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryBenchmarkTests.swift index 4f42c5a55..639cbe80e 100644 --- a/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryBenchmarkTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryBenchmarkTests.swift @@ -350,17 +350,29 @@ struct MessagesRepositoryBenchmarkTests { .including( required: DBConversation.creator .forKey("conversationCreator") - .select([DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, + DBConversationMember.Columns.role, + DBConversationMember.Columns.createdAt, + ]) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) ) .including(required: DBConversation.localState) .including( all: DBConversation._members .forKey("conversationMembers") - .select([DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, + DBConversationMember.Columns.role, + DBConversationMember.Columns.createdAt, + ]) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) ) .asRequest(of: DBConversationDetails.self) .fetchOne(db) @@ -496,9 +508,15 @@ struct MessagesRepositoryBenchmarkTests { .including( required: DBMessage.sender .forKey("messageSender") - .select([DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, + DBConversationMember.Columns.role, + DBConversationMember.Columns.createdAt, + ]) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) ) .including(optional: DBMessage.sourceMessage) .asRequest(of: MessageWithDetailsLite.self) @@ -514,9 +532,15 @@ struct MessagesRepositoryBenchmarkTests { .including( required: DBMessage.sender .forKey("messageSender") - .select([DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, + DBConversationMember.Columns.role, + DBConversationMember.Columns.createdAt, + ]) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) ) .asRequest(of: MessageSenderOnly.self) .fetchAll(db) @@ -575,17 +599,29 @@ struct MessagesRepositoryBenchmarkTests { .including( required: DBConversation.creator .forKey("conversationCreator") - .select([DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, + DBConversationMember.Columns.role, + DBConversationMember.Columns.createdAt, + ]) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) ) .including(required: DBConversation.localState) .including( all: DBConversation._members .forKey("conversationMembers") - .select([DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, + DBConversationMember.Columns.role, + DBConversationMember.Columns.createdAt, + ]) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) ) .asRequest(of: DBConversationDetails.self) .fetchOne(db) @@ -594,11 +630,14 @@ struct MessagesRepositoryBenchmarkTests { _ = try DBConversationMember .filter(DBConversationMember.Columns.conversationId == conversationId) .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt, ]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) .asRequest(of: DBConversationMemberProfileWithRole.self) .fetchAll(db) let t2 = CFAbsoluteTimeGetCurrent() @@ -611,9 +650,15 @@ struct MessagesRepositoryBenchmarkTests { .including( required: DBMessage.sender .forKey("messageSender") - .select([DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, + DBConversationMember.Columns.role, + DBConversationMember.Columns.createdAt, + ]) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) ) .including(all: DBMessage.reactions) .including(optional: DBMessage.sourceMessage) @@ -630,9 +675,15 @@ struct MessagesRepositoryBenchmarkTests { .including( required: DBMessage.sender .forKey("messageSender") - .select([DBConversationMember.Columns.role, DBConversationMember.Columns.createdAt]) - .including(required: DBConversationMember.memberProfile) - .including(optional: DBConversationMember.inviterProfile) + .select([ + DBConversationMember.Columns.conversationId, + DBConversationMember.Columns.inboxId, + DBConversationMember.Columns.role, + DBConversationMember.Columns.createdAt, + ]) + .including(optional: DBConversationMember.profile) + .including(optional: DBConversationMember.avatarSlot) + .including(optional: DBConversationMember.inviterProfileIdentity) ) .including(optional: DBMessage.sourceMessage) .asRequest(of: MessageWithDetailsLite.self) diff --git a/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryTests.swift b/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryTests.swift index af5bb3761..bbf2644cc 100644 --- a/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/MessagesRepositoryTests.swift @@ -104,9 +104,10 @@ struct MessagesRepositoryTests { .filter(DBConversationMember.Columns.inboxId == removedInboxId) .deleteAll(db) - try DBMemberProfile - .filter(DBMemberProfile.Columns.conversationId == conversationId) - .filter(DBMemberProfile.Columns.inboxId == removedInboxId) + // Also drop the canonical identity: with neither a roster row nor a + // DBProfile, the sender falls through to the "Somebody" placeholder. + try DBProfile + .filter(DBProfile.Columns.inboxId == removedInboxId) .deleteAll(db) } @@ -448,6 +449,23 @@ struct MessagesRepositoryTests { #expect(!live.isInviteExpired) } + @Test("MemberProfileCache resolves a left self participant from DBMyProfile") + func testCacheResolvesHistoricalSelfFromMyProfile() { + // Self is excluded from DBProfile, so a current user who left the roster + // but authored history must resolve from DBMyProfile, not fall to empty. + let cache = MemberProfileCache( + activeProfiles: [], + historicalProfiles: [], + historicalSelfProfile: DBMyProfile(inboxId: "me", name: "Ziggy", updatedAt: Date(timeIntervalSince1970: 1)), + historicalSelfAvatar: nil, + conversationId: "c1", + currentInboxId: "me" + ) + let member = cache.member(for: "me") + #expect(member?.profile.name == "Ziggy") + #expect(member?.isCurrentUser == true) + } + // MARK: - Helpers private func seedConversation( @@ -510,12 +528,12 @@ struct MessagesRepositoryTests { invitedByInboxId: nil ).insert(db) - try DBMemberProfile( - conversationId: conversationId, - inboxId: currentInboxId, - name: "Current", - avatar: nil - ).insert(db) + // Seed both tables: canonical `DBProfile` is what the read path uses + // (and persists per-inbox after a member is removed), while the legacy + // per-conversation `DBMemberProfile` is still written defensively and has + // its own persistence assertions below. + try DBMemberProfile(conversationId: conversationId, inboxId: currentInboxId, name: "Current", avatar: nil).insert(db) + try DBProfile(inboxId: currentInboxId, name: "Current", profileSource: .profileUpdate, updatedAt: now).save(db) for inboxId in otherInboxIds { try DBConversationMember( @@ -527,12 +545,8 @@ struct MessagesRepositoryTests { invitedByInboxId: nil ).insert(db) - try DBMemberProfile( - conversationId: conversationId, - inboxId: inboxId, - name: "Removed", - avatar: nil - ).insert(db) + try DBMemberProfile(conversationId: conversationId, inboxId: inboxId, name: "Removed", avatar: nil).insert(db) + try DBProfile(inboxId: inboxId, name: "Removed", profileSource: .profileUpdate, updatedAt: now).save(db) } } } diff --git a/ConvosCore/Tests/ConvosCoreTests/MyProfileRepositoryTests.swift b/ConvosCore/Tests/ConvosCoreTests/MyProfileRepositoryTests.swift new file mode 100644 index 000000000..59e99e837 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/MyProfileRepositoryTests.swift @@ -0,0 +1,67 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Coverage for `MyProfileRepository.observedProfile`: the current user's +/// identity comes from `DBMyProfile` and the avatar from the newest +/// `DBProfileAvatar` slot, so a self-avatar upload surfaces on the My Profile +/// screen instead of showing a blank photo. +@Suite("MyProfileRepository self profile") +struct MyProfileRepositoryTests { + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + + @Test("surfaces the latest self avatar alongside the name") + func surfacesSelfAvatar() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1", "c2"]) + try await queue.write { db in + try DBMyProfile(inboxId: "me", name: "Me").save(db) + try DBProfileAvatar( + inboxId: "me", conversationId: "c1", url: "old", salt: salt, nonce: nonce, + encryptionKey: key, profileSource: .profileUpdate, updatedAt: Date(timeIntervalSince1970: 1) + ).save(db) + try DBProfileAvatar( + inboxId: "me", conversationId: "c2", url: "new", salt: salt, nonce: nonce, + encryptionKey: key, profileSource: .profileUpdate, updatedAt: Date(timeIntervalSince1970: 5) + ).save(db) + } + + let profile = try await queue.read { db in + try MyProfileRepository.observedProfile(db, inboxId: "me", conversationId: "c1") + } + + #expect(profile.name == "Me") + // Newest slot wins. + #expect(profile.avatar == "new") + #expect(profile.isAvatarEncrypted) + } + + @Test("no avatar slot leaves the avatar nil but keeps the name") + func nameOnlyWhenNoAvatar() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + try await queue.write { db in + try DBMyProfile(inboxId: "me", name: "Me").save(db) + } + + let profile = try await queue.read { db in + try MyProfileRepository.observedProfile(db, inboxId: "me", conversationId: "c1") + } + + #expect(profile.name == "Me") + #expect(profile.avatar == nil) + } + + @Test("no self row resolves to an empty profile") + func emptyWhenNoSelfRow() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + + let profile = try await queue.read { db in + try MyProfileRepository.observedProfile(db, inboxId: "me", conversationId: "c1") + } + + #expect(profile.name == nil) + #expect(profile.avatar == nil) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/NotificationMemberDisplayNameTests.swift b/ConvosCore/Tests/ConvosCoreTests/NotificationMemberDisplayNameTests.swift index b2f811ee9..286f0daeb 100644 --- a/ConvosCore/Tests/ConvosCoreTests/NotificationMemberDisplayNameTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/NotificationMemberDisplayNameTests.swift @@ -36,14 +36,17 @@ struct NotificationMemberDisplayNameTests { ).insert(db) } + /// Seeds the member's canonical identity. `notificationMemberDisplayName` + /// reads `DBProfile` (per-inbox), not the legacy per-conversation + /// `DBMemberProfile`. private func seedMemberProfile(db: Database, name: String?, memberKind: DBMemberKind? = nil) throws { - try DBMemberProfile( - conversationId: conversationId, + try DBProfile( inboxId: memberInboxId, name: name, - avatar: nil, - memberKind: memberKind - ).insert(db) + memberKind: memberKind, + profileSource: .profileUpdate, + updatedAt: Date() + ).save(db) } private func seedContact(db: Database, displayName: String?) throws { diff --git a/ConvosCore/Tests/ConvosCoreTests/NotificationTitleMembershipTests.swift b/ConvosCore/Tests/ConvosCoreTests/NotificationTitleMembershipTests.swift index 3d44f5676..befbf1298 100644 --- a/ConvosCore/Tests/ConvosCoreTests/NotificationTitleMembershipTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/NotificationTitleMembershipTests.swift @@ -40,14 +40,18 @@ struct NotificationTitleMembershipTests { ).insert(db) } + /// Seeds a member's canonical identity. `computedDisplayName` resolves names + /// from `DBProfile` (per-inbox), gated on current `conversation_members` + /// membership - so an orphan with a profile but no membership row is still + /// excluded from the title. private func seedMemberProfile(db: Database, inboxId: String, name: String?, memberKind: DBMemberKind? = nil) throws { - try DBMemberProfile( - conversationId: conversationId, + try DBProfile( inboxId: inboxId, name: name, - avatar: nil, - memberKind: memberKind - ).insert(db) + memberKind: memberKind, + profileSource: .profileUpdate, + updatedAt: Date() + ).save(db) } private func seedConversationMember(db: Database, inboxId: String, role: MemberRole) throws { diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileAvatarLatestViewTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileAvatarLatestViewTests.swift new file mode 100644 index 000000000..42aebd35d --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileAvatarLatestViewTests.swift @@ -0,0 +1,61 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Guards that the `profileAvatarLatest` view returns exactly one deterministic +/// row per inbox, even when several slots share the same `updatedAt` (which +/// `ProfileBackfill` produces by writing every legacy avatar at the epoch floor). +@Suite("profileAvatarLatest view determinism") +struct ProfileAvatarLatestViewTests { + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + + private func avatar(_ conversationId: String, url: String, updatedAt: Date) -> DBProfileAvatar { + DBProfileAvatar( + inboxId: "me", conversationId: conversationId, url: url, salt: salt, nonce: nonce, + encryptionKey: key, profileSource: .contact, updatedAt: updatedAt + ) + } + + @Test("one deterministic row per inbox when updatedAt ties") + func deterministicOnTies() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1", "c2"]) + let time = Date(timeIntervalSince1970: 1) + try await queue.write { db in + try avatar("c1", url: "u1", updatedAt: time).save(db) + try avatar("c2", url: "u2", updatedAt: time).save(db) + } + + // Repeated reads must return the same single row (conversationId DESC + // tie-breaker -> "c2"). + for _ in 0..<3 { + let rows = try await queue.read { db in + try DBProfileAvatarLatest + .filter(DBProfileAvatarLatest.Columns.inboxId == "me") + .fetchAll(db) + } + #expect(rows.count == 1) + #expect(rows.first?.conversationId == "c2") + #expect(rows.first?.url == "u2") + } + } + + @Test("newest updatedAt wins regardless of conversationId") + func newestWins() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1", "c2"]) + try await queue.write { db in + try avatar("c2", url: "old", updatedAt: Date(timeIntervalSince1970: 1)).save(db) + try avatar("c1", url: "new", updatedAt: Date(timeIntervalSince1970: 5)).save(db) + } + + let row = try await queue.read { db in + try DBProfileAvatarLatest + .filter(DBProfileAvatarLatest.Columns.inboxId == "me") + .fetchOne(db) + } + #expect(row?.url == "new") + #expect(row?.conversationId == "c1") + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileBackfillTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileBackfillTests.swift new file mode 100644 index 000000000..bc113a59a --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileBackfillTests.swift @@ -0,0 +1,157 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +@Suite("ProfileBackfill", .serialized) +struct ProfileBackfillTests { + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + + /// Builds a queue with just the legacy `memberProfile` columns the + /// `DBMemberProfile` record decodes - enough to seed rows and read them back. + private func makeMemberProfileQueue() throws -> DatabaseQueue { + let queue = try DatabaseQueue() + try queue.write { db in + try db.create(table: "memberProfile") { t in + t.column("conversationId", .text).notNull() + t.column("inboxId", .text).notNull() + t.column("name", .text) + t.column("avatar", .text) + t.column("avatarSalt", .blob) + t.column("avatarNonce", .blob) + t.column("avatarKey", .blob) + t.column("avatarLastRenewed", .datetime) + t.column("imageSourceAssetIdentifier", .text) + t.column("imageSourceContentDigest", .text) + t.column("memberKind", .text) + t.column("metadata", .jsonText) + t.primaryKey(["conversationId", "inboxId"]) + } + } + return queue + } + + private func seed(_ queue: DatabaseQueue, _ rows: [DBMemberProfile]) throws { + try queue.write { db in + for row in rows { + try row.save(db) + } + } + } + + @Test("migrates other members' identity + avatar, skips self identity but keeps self avatar") + func backfillsAll() async throws { + let queue = try makeMemberProfileQueue() + try seed(queue, [ + DBMemberProfile(conversationId: "c1", inboxId: "alice", name: "Alice", avatar: "u", avatarSalt: salt, avatarNonce: nonce, avatarKey: key), + DBMemberProfile(conversationId: "c1", inboxId: "me", name: "Me", avatar: "meurl", avatarSalt: salt, avatarNonce: nonce, avatarKey: key), + ]) + let profileStore = InMemoryProfileStore() + let backfill = ProfileBackfill(databaseReader: queue, profileStore: profileStore, selfInboxId: "me") + + try await backfill.run() + + let alice = try await profileStore.identity(inboxId: "alice") + #expect(alice?.name == "Alice") + #expect(alice?.profileSource == .contact) + let aliceAvatar = try await profileStore.avatar(inboxId: "alice", conversationId: "c1") + #expect(aliceAvatar?.url == "u") + + // Self identity is not written to `DBProfile` (it lives in `myProfile`), + // but the self avatar is still backfilled. + let meProfile = try await profileStore.identity(inboxId: "me") + #expect(meProfile == nil) + let meAvatar = try await profileStore.avatar(inboxId: "me", conversationId: "c1") + #expect(meAvatar?.url == "meurl") + } + + @Test("is idempotent - a second run produces the same result") + func idempotent() async throws { + let queue = try makeMemberProfileQueue() + try seed(queue, [ + DBMemberProfile(conversationId: "c1", inboxId: "alice", name: "Alice", avatar: "u", avatarSalt: salt, avatarNonce: nonce, avatarKey: key), + ]) + let profileStore = InMemoryProfileStore() + let backfill = ProfileBackfill(databaseReader: queue, profileStore: profileStore, selfInboxId: "me") + + try await backfill.run() + try await backfill.run() + + let identities = try await profileStore.allIdentities() + #expect(identities.count == 1) + let avatars = try await profileStore.allAvatars() + #expect(avatars.count == 1) + } + + @Test("never overwrites a value already set by a real event") + func doesNotClobberRealData() async throws { + let queue = try makeMemberProfileQueue() + try seed(queue, [ + DBMemberProfile(conversationId: "c1", inboxId: "alice", name: "Legacy", avatar: nil), + ]) + let profileStore = InMemoryProfileStore() + // A real profileUpdate already landed for alice. + try await profileStore.saveIdentity( + DBProfile(inboxId: "alice", name: "Real", profileSource: .profileUpdate, updatedAt: Date(timeIntervalSince1970: 100)) + ) + let backfill = ProfileBackfill(databaseReader: queue, profileStore: profileStore, selfInboxId: "me") + + try await backfill.run() + + let alice = try await profileStore.identity(inboxId: "alice") + #expect(alice?.name == "Real") + #expect(alice?.profileSource == .profileUpdate) + } + + @Test("mirror(_:) populates the stores from provided rows") + func mirrorFromRows() async throws { + let profileStore = InMemoryProfileStore() + let backfill = ProfileBackfill(databaseReader: try DatabaseQueue(), profileStore: profileStore, selfInboxId: "me") + + try await backfill.mirror([ + DBMemberProfile(conversationId: "c1", inboxId: "alice", name: "Alice", avatar: "u", avatarSalt: salt, avatarNonce: nonce, avatarKey: key), + ]) + + let alice = try await profileStore.identity(inboxId: "alice") + #expect(alice?.name == "Alice") + let avatar = try await profileStore.avatar(inboxId: "alice", conversationId: "c1") + #expect(avatar?.url == "u") + } + + @Test("mirror(_:) tracks a changed value on re-run") + func mirrorTracksChange() async throws { + let profileStore = InMemoryProfileStore() + let backfill = ProfileBackfill(databaseReader: try DatabaseQueue(), profileStore: profileStore, selfInboxId: "me") + + try await backfill.mirror([DBMemberProfile(conversationId: "c1", inboxId: "alice", name: "Alice", avatar: nil)]) + try await backfill.mirror([DBMemberProfile(conversationId: "c1", inboxId: "alice", name: "Alicia", avatar: nil)]) + + let alice = try await profileStore.identity(inboxId: "alice") + #expect(alice?.name == "Alicia") + } + + @Test("mirror(_:) skips the current user's identity") + func mirrorSkipsSelfIdentity() async throws { + let profileStore = InMemoryProfileStore() + let backfill = ProfileBackfill(databaseReader: try DatabaseQueue(), profileStore: profileStore, selfInboxId: "me") + + try await backfill.mirror([DBMemberProfile(conversationId: "c1", inboxId: "me", name: "Me", avatar: nil)]) + + let me = try await profileStore.identity(inboxId: "me") + #expect(me == nil) + } + + @Test("does nothing when there are no legacy rows") + func emptyIsNoop() async throws { + let queue = try makeMemberProfileQueue() + let profileStore = InMemoryProfileStore() + let backfill = ProfileBackfill(databaseReader: queue, profileStore: profileStore, selfInboxId: "me") + + try await backfill.run() + + let identities = try await profileStore.allIdentities() + #expect(identities.isEmpty) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileInboundApplierTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileInboundApplierTests.swift new file mode 100644 index 000000000..ef8211c94 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileInboundApplierTests.swift @@ -0,0 +1,211 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +@Suite("ProfileInboundApplier") +struct ProfileInboundApplierTests { + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + + /// Full schema via the shared migrator, so the applier's canonical writes and + /// the `DBContact` mirror both have their tables. Agent attestation / + /// `hasHadVerifiedAgent` behaviour is exercised by the integration tests, so + /// these tests use non-agent members. + private func makeQueue(conversations: [String] = ["c1"]) throws -> DatabaseQueue { + let queue = try DatabaseQueue() + try SharedDatabaseMigrator.shared.migrate(database: queue) + try queue.write { db in + for id in conversations { + try seedConversation(db, id: id) + } + } + return queue + } + + private func seedConversation(_ db: Database, id: String) throws { + try DBMember(inboxId: "creator").save(db, onConflict: .ignore) + try DBConversation( + id: id, + clientConversationId: id, + inviteTag: "tag-\(id)", + creatorId: "creator", + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: true, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + conversationEmoji: nil, + imageLastRenewed: nil, + isUnused: false, + hasHadVerifiedAgent: false + ).insert(db) + } + + private func imageRef(url: String) -> EncryptedProfileImageRef { + var ref = EncryptedProfileImageRef() + ref.url = url + ref.salt = salt + ref.nonce = nonce + return ref + } + + private func apply( + _ queue: DatabaseQueue, + inboxId: String, + source: ProfileSource = .profileUpdate, + name: String?, + avatar: ProfileInboundApplier.AvatarDisposition, + selfInboxId: String? = "me", + fallbackKey: Data? = nil, + sentAt: Date + ) throws { + try queue.write { db in + try ProfileInboundApplier.apply( + db: db, + conversationId: "c1", + event: ProfileInboundApplier.Incoming( + inboxId: inboxId, + source: source, + name: name, + avatar: avatar, + memberKind: nil, + metadata: nil, + receivedAt: sentAt + ), + selfInboxId: selfInboxId, + fallbackEncryptionKey: fallbackKey + ) + } + } + + private func profile(_ queue: DatabaseQueue, inboxId: String) throws -> DBProfile? { + try queue.read { db in try DBProfile.fetchOne(db, inboxId: inboxId) } + } + + private func avatar(_ queue: DatabaseQueue, inboxId: String) throws -> DBProfileAvatar? { + try queue.read { db in try DBProfileAvatar.fetchOne(db, inboxId: inboxId, conversationId: "c1") } + } + + @Test("an update writes identity and an avatar slot") + func updateWritesIdentityAndAvatar() throws { + let queue = try makeQueue() + try apply(queue, inboxId: "alice", name: "Alice", avatar: .addressed(imageRef(url: "u")), fallbackKey: key, sentAt: Date(timeIntervalSince1970: 1)) + + let alice = try profile(queue, inboxId: "alice") + #expect(alice?.name == "Alice") + #expect(alice?.profileSource == .profileUpdate) + let slot = try avatar(queue, inboxId: "alice") + #expect(slot?.url == "u") + #expect(slot?.encryptionKey == key) + } + + @Test("an event authored by the current user is not written to the profile tables") + func selfEchoSkipped() throws { + let queue = try makeQueue() + try apply(queue, inboxId: "me", name: "Myself", avatar: .addressed(imageRef(url: "u")), selfInboxId: "me", sentAt: Date(timeIntervalSince1970: 1)) + + let me = try profile(queue, inboxId: "me") + #expect(me == nil) + let slot = try avatar(queue, inboxId: "me") + #expect(slot == nil) + } + + @Test("a snapshot with no image leaves the avatar slot untouched") + func snapshotFillIfPresentIsSilentWithoutImage() throws { + let queue = try makeQueue() + try apply(queue, inboxId: "alice", source: .profileSnapshot, name: "Alice", avatar: .fillIfPresent(nil), sentAt: Date(timeIntervalSince1970: 1)) + + let alice = try profile(queue, inboxId: "alice") + #expect(alice?.name == "Alice") + let slot = try avatar(queue, inboxId: "alice") + #expect(slot == nil) + } + + @Test("an update with no image leaves an existing avatar untouched (deferred deliberate-clear)") + func updateAddressedKeepsAvatarWhenImageAbsent() throws { + let queue = try makeQueue() + try apply(queue, inboxId: "alice", name: "Alice", avatar: .addressed(imageRef(url: "u")), fallbackKey: key, sentAt: Date(timeIntervalSince1970: 1)) + try apply(queue, inboxId: "alice", name: "Alice", avatar: .addressed(nil), sentAt: Date(timeIntervalSince1970: 2)) + + // Until the wire format can signal a deliberate clear, an omitted image + // is "no change", not a clear - so the existing avatar is preserved. + let slot = try avatar(queue, inboxId: "alice") + #expect(slot?.url == "u") + #expect(slot?.encryptionKey == key) + } + + @Test("an update with a malformed image ref leaves an existing avatar untouched") + func updateAddressedKeepsAvatarWhenImageMalformed() throws { + let queue = try makeQueue() + try apply(queue, inboxId: "alice", name: "Alice", avatar: .addressed(imageRef(url: "u")), fallbackKey: key, sentAt: Date(timeIntervalSince1970: 1)) + // A set-but-invalid ref (no url) must never wipe a good avatar. + var malformed = EncryptedProfileImageRef() + malformed.salt = salt + malformed.nonce = nonce + try apply(queue, inboxId: "alice", name: "Alice", avatar: .addressed(malformed), sentAt: Date(timeIntervalSince1970: 2)) + + let slot = try avatar(queue, inboxId: "alice") + #expect(slot?.url == "u") + } + + @Test("a lower-source snapshot does not override an update-sourced name") + func precedenceHoldsThroughApplier() throws { + let queue = try makeQueue() + try apply(queue, inboxId: "alice", source: .profileUpdate, name: "Real", avatar: .fillIfPresent(nil), sentAt: Date(timeIntervalSince1970: 2)) + try apply(queue, inboxId: "alice", source: .profileSnapshot, name: "Snapshot", avatar: .fillIfPresent(nil), sentAt: Date(timeIntervalSince1970: 1)) + + let alice = try profile(queue, inboxId: "alice") + #expect(alice?.name == "Real") + #expect(alice?.profileSource == .profileUpdate) + } + + @Test("a nil selfInboxId still writes other members") + func nilSelfStillWritesOthers() throws { + let queue = try makeQueue() + try apply(queue, inboxId: "alice", name: "Alice", avatar: .fillIfPresent(nil), selfInboxId: nil, sentAt: Date(timeIntervalSince1970: 1)) + + let alice = try profile(queue, inboxId: "alice") + #expect(alice?.name == "Alice") + } + + @Test("an inbound update refreshes an existing contact's name and avatar") + func mirrorsToExistingContact() throws { + let queue = try makeQueue() + try queue.write { db in + try DBContact( + inboxId: "alice", + addedAt: Date(timeIntervalSince1970: 0), + addedViaConversationId: "c1", + displayName: "Old Alice", + avatarURL: nil, + profileUpdatedAt: Date(timeIntervalSince1970: 0) + ).insert(db) + } + + try apply(queue, inboxId: "alice", name: "New Alice", avatar: .addressed(imageRef(url: "u")), fallbackKey: key, sentAt: Date(timeIntervalSince1970: 1)) + + let contact = try queue.read { db in try DBContact.fetchOne(db, key: "alice") } + #expect(contact?.displayName == "New Alice") + #expect(contact?.avatarURL == "u") + } + + @Test("an inbound update does not create a contact for a non-contact member") + func doesNotCreateContact() throws { + let queue = try makeQueue() + try apply(queue, inboxId: "bob", name: "Bob", avatar: .fillIfPresent(nil), sentAt: Date(timeIntervalSince1970: 1)) + + let contact = try queue.read { db in try DBContact.fetchOne(db, key: "bob") } + #expect(contact == nil) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileMergeTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileMergeTests.swift new file mode 100644 index 000000000..f80f2a845 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileMergeTests.swift @@ -0,0 +1,225 @@ +@testable import ConvosCore +import Foundation +import Testing + +@Suite("Profile merge - identity") +struct ProfileMergeIdentityTests { + private let t1 = Date(timeIntervalSince1970: 100) + private let t2 = Date(timeIntervalSince1970: 200) + + @Test("creates a new profile from no existing row, dropping a blank name") + func createsNew() { + let blank = ProfileMerge.mergeIdentity( + existing: nil, inboxId: "i", incoming: IncomingIdentity(name: " "), + source: .profileUpdate, sentAt: t1 + ) + #expect(blank.name == nil) + #expect(blank.profileSource == .profileUpdate) + #expect(blank.updatedAt == t1) + + let named = ProfileMerge.mergeIdentity( + existing: nil, inboxId: "i", incoming: IncomingIdentity(name: "Alice"), + source: .contact, sentAt: t1 + ) + #expect(named.name == "Alice") + #expect(named.profileSource == .contact) + } + + @Test("an empty incoming name never clears a populated name") + func nameNeverCleared() { + let existing = DBProfile(inboxId: "i", name: "Alice", profileSource: .profileUpdate, updatedAt: t1) + let merged = ProfileMerge.mergeIdentity( + existing: existing, inboxId: "i", incoming: IncomingIdentity(name: ""), + source: .profileUpdate, sentAt: t2 + ) + #expect(merged.name == "Alice") + #expect(merged.updatedAt == t2) + } + + @Test("higher source overwrites a lower-source value") + func higherSourceWins() { + let existing = DBProfile(inboxId: "i", name: "Old", profileSource: .contact, updatedAt: t1) + let merged = ProfileMerge.mergeIdentity( + existing: existing, inboxId: "i", incoming: IncomingIdentity(name: "New"), + source: .profileUpdate, sentAt: t1 + ) + #expect(merged.name == "New") + #expect(merged.profileSource == .profileUpdate) + } + + @Test("within the same source, newer wins and older is dropped") + func recencyWithinSource() { + let existing = DBProfile(inboxId: "i", name: "A", profileSource: .profileUpdate, updatedAt: t1) + let newer = ProfileMerge.mergeIdentity( + existing: existing, inboxId: "i", incoming: IncomingIdentity(name: "B"), + source: .profileUpdate, sentAt: t2 + ) + #expect(newer.name == "B") + + let existing2 = DBProfile(inboxId: "i", name: "B", profileSource: .profileUpdate, updatedAt: t2) + let older = ProfileMerge.mergeIdentity( + existing: existing2, inboxId: "i", incoming: IncomingIdentity(name: "A"), + source: .profileUpdate, sentAt: t1 + ) + #expect(older.name == "B") + #expect(older.updatedAt == t2) + } + + @Test("a lower source fills a blank name without changing provenance") + func lowerSourceFillsBlank() { + let existing = DBProfile(inboxId: "i", name: nil, profileSource: .profileUpdate, updatedAt: t2) + let merged = ProfileMerge.mergeIdentity( + existing: existing, inboxId: "i", incoming: IncomingIdentity(name: "Filled"), + source: .contact, sentAt: t1 + ) + #expect(merged.name == "Filled") + #expect(merged.profileSource == .profileUpdate) + #expect(merged.updatedAt == t2) + } + + @Test("a verified assistant kind is never downgraded to generic agent") + func preservesVerifiedKind() { + let existing = DBProfile(inboxId: "i", memberKind: .verifiedConvos, profileSource: .profileUpdate, updatedAt: t1) + let downgrade = ProfileMerge.mergeIdentity( + existing: existing, inboxId: "i", incoming: IncomingIdentity(memberKind: .agent), + source: .profileUpdate, sentAt: t2 + ) + #expect(downgrade.memberKind == .verifiedConvos) + + let upgradeFrom = DBProfile(inboxId: "i", memberKind: .agent, profileSource: .profileUpdate, updatedAt: t1) + let upgrade = ProfileMerge.mergeIdentity( + existing: upgradeFrom, inboxId: "i", incoming: IncomingIdentity(memberKind: .verifiedConvos), + source: .profileUpdate, sentAt: t2 + ) + #expect(upgrade.memberKind == .verifiedConvos) + } +} + +@Suite("Profile merge - avatar") +struct ProfileMergeAvatarTests { + private let t1 = Date(timeIntervalSince1970: 100) + private let t2 = Date(timeIntervalSince1970: 200) + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + + private func setAvatar(_ url: String) -> IncomingAvatar { + .set(url: url, salt: salt, nonce: nonce, key: key) + } + + @Test("silent leaves the slot untouched") + func silentLeavesSlot() { + let existing = DBProfileAvatar(inboxId: "i", conversationId: "c", url: "x", profileSource: .profileUpdate, updatedAt: t1) + let merged = ProfileMerge.mergeAvatar( + existing: existing, inboxId: "i", conversationId: "c", incoming: .silent, + source: .profileUpdate, sentAt: t2 + ) + #expect(merged?.url == "x") + let none = ProfileMerge.mergeAvatar( + existing: nil, inboxId: "i", conversationId: "c", incoming: .silent, + source: .profileUpdate, sentAt: t2 + ) + #expect(none == nil) + } + + @Test("set creates a slot when none exists") + func setCreatesSlot() { + let merged = ProfileMerge.mergeAvatar( + existing: nil, inboxId: "i", conversationId: "c", incoming: setAvatar("u"), + source: .profileUpdate, sentAt: t1 + ) + #expect(merged?.url == "u") + #expect(merged?.hasValidEncryptedAvatar == true) + #expect(merged?.profileSource == .profileUpdate) + } + + @Test("explicit clear records a tombstone that survives a later silent") + func explicitClearTombstones() { + let cleared = ProfileMerge.mergeAvatar( + existing: DBProfileAvatar(inboxId: "i", conversationId: "c", url: "old", profileSource: .profileUpdate, updatedAt: t1), + inboxId: "i", conversationId: "c", incoming: .explicitClear, + source: .profileUpdate, sentAt: t2 + ) + #expect(cleared?.url == nil) + #expect(cleared?.updatedAt == t2) + + let afterSilent = ProfileMerge.mergeAvatar( + existing: cleared, inboxId: "i", conversationId: "c", incoming: .silent, + source: .profileUpdate, sentAt: t2 + ) + #expect(afterSilent?.url == nil) + } + + @Test("a lower or older event never overrides or resurrects a slot") + func lowerOrOlderIgnored() { + // Lower-source set does not override a higher-source value. + let high = DBProfileAvatar(inboxId: "i", conversationId: "c", url: "keep", profileSource: .profileUpdate, updatedAt: t1) + let lowerSet = ProfileMerge.mergeAvatar( + existing: high, inboxId: "i", conversationId: "c", incoming: setAvatar("ignored"), + source: .profileSnapshot, sentAt: t2 + ) + #expect(lowerSet?.url == "keep") + + // Lower-source set does not resurrect a tombstone. + let tombstone = DBProfileAvatar(inboxId: "i", conversationId: "c", url: nil, profileSource: .profileUpdate, updatedAt: t2) + let resurrect = ProfileMerge.mergeAvatar( + existing: tombstone, inboxId: "i", conversationId: "c", incoming: setAvatar("ignored"), + source: .contact, sentAt: t1 + ) + #expect(resurrect?.url == nil) + } + + @Test("newer set within the same source replaces an older value") + func newerSetReplaces() { + let existing = DBProfileAvatar(inboxId: "i", conversationId: "c", url: "old", profileSource: .profileUpdate, updatedAt: t1) + let merged = ProfileMerge.mergeAvatar( + existing: existing, inboxId: "i", conversationId: "c", incoming: setAvatar("new"), + source: .profileUpdate, sentAt: t2 + ) + #expect(merged?.url == "new") + } + + @Test("a set missing the encryption key is rejected and never downgrades an encrypted slot") + func keylessSetRejected() { + let keyless: IncomingAvatar = .set(url: "u", salt: nil, nonce: nil, key: nil) + + // No existing slot: a keyless set stores nothing (no plaintext avatar). + let fresh = ProfileMerge.mergeAvatar( + existing: nil, inboxId: "i", conversationId: "c", incoming: keyless, + source: .profileUpdate, sentAt: t1 + ) + #expect(fresh == nil) + + // Existing encrypted slot: a newer keyless set does not overwrite it. + let encrypted = DBProfileAvatar( + inboxId: "i", conversationId: "c", url: "enc", salt: salt, nonce: nonce, + encryptionKey: key, profileSource: .profileUpdate, updatedAt: t1 + ) + let preserved = ProfileMerge.mergeAvatar( + existing: encrypted, inboxId: "i", conversationId: "c", incoming: keyless, + source: .profileUpdate, sentAt: t2 + ) + #expect(preserved?.url == "enc") + #expect(preserved?.hasValidEncryptedAvatar == true) + } +} + +@Suite("SelfProfileEdit") +struct SelfProfileEditTests { + private let t1 = Date(timeIntervalSince1970: 1) + private let t2 = Date(timeIntervalSince1970: 2) + + @Test("keep leaves a field unchanged; set replaces it") + func appliesPartialEdit() { + let base = DBMyProfile(inboxId: "me", name: "Old", metadata: ["k": .string("v")], updatedAt: t1) + + let nameOnly = SelfProfileEdit(name: .set("New")).applied(to: base, updatedAt: t2) + #expect(nameOnly.name == "New") + #expect(nameOnly.metadata?["k"]?.stringValue == "v") + #expect(nameOnly.updatedAt == t2) + + let metadataOnly = SelfProfileEdit(metadata: .set(nil)).applied(to: base, updatedAt: t2) + #expect(metadataOnly.name == "Old") + #expect(metadataOnly.metadata == nil) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileMetadataWriterTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileMetadataWriterTests.swift index 5212c8201..c1fd24009 100644 --- a/ConvosCore/Tests/ConvosCoreTests/ProfileMetadataWriterTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileMetadataWriterTests.swift @@ -4,150 +4,112 @@ import GRDB import Testing /// Tests for ProfileMetadataWriter, the shared serialization choke point for -/// per-sender ProfileUpdate.metadata writes. +/// self-profile metadata writes. +/// +/// The writer reads the current user's `selfProfile` metadata, applies the +/// caller's closure, and republishes the merged map through +/// `ProfilesRepository.publishMyProfileMetadata` (which persists it back to the +/// `selfProfile` row and, when a session is attached, fans it out). /// /// Covers: -/// - read existing metadata -> apply closure -> publish merged map -/// - empty merged map publishes nil (so an empty map clears rather than writes) +/// - read existing metadata -> apply closure -> persist merged map +/// - empty merged map clears the metadata /// - concurrent writers to different keys both survive (no clobber) @Suite("ProfileMetadataWriter Tests") struct ProfileMetadataWriterTests { private struct Fixture { let databaseManager: MockDatabaseManager - let profileWriter: MockMyProfileWriter + let inboxId: String let writer: ProfileMetadataWriter - init() { + init(inboxId: String = "self-inbox") { let databaseManager = MockDatabaseManager.makeTestDatabase() - let profileWriter = MockMyProfileWriter() self.databaseManager = databaseManager - self.profileWriter = profileWriter + self.inboxId = inboxId + let repository = ProfilesRepository( + profileStore: GRDBProfileStore( + databaseWriter: databaseManager.dbWriter, + databaseReader: databaseManager.dbReader + ), + selfProfileStore: GRDBSelfProfileStore( + databaseWriter: databaseManager.dbWriter, + databaseReader: databaseManager.dbReader, + selfInboxIdProvider: { inboxId } + ), + publishStore: GRDBProfilePublishStore( + databaseWriter: databaseManager.dbWriter, + databaseReader: databaseManager.dbReader + ), + databaseReader: databaseManager.dbReader, + conversationLocalStateWriter: ConversationLocalStateWriter(databaseWriter: databaseManager.dbWriter), + selfInboxIdProvider: { inboxId } + ) self.writer = ProfileMetadataWriter( - myProfileWriter: profileWriter, + profilesRepository: { repository }, databaseReader: databaseManager.dbReader ) } - func seedConversation(id: String) throws { - let conversation = DBConversation( - id: id, - clientConversationId: id, - inviteTag: "invite-\(id)", - creatorId: "test-inbox", - 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, - conversationEmoji: nil, - imageLastRenewed: nil, - isUnused: false, - hasHadVerifiedAgent: false, - ) + func seedSelfMetadata(_ metadata: ProfileMetadata?) throws { + let profile = DBMyProfile(inboxId: inboxId, metadata: metadata, updatedAt: Date()) try databaseManager.dbWriter.write { db in - try conversation.save(db) + try profile.save(db) } } - func seedMemberProfile( - conversationId: String, - inboxId: String, - metadata: ProfileMetadata? - ) throws { - try databaseManager.dbWriter.write { db in - try DBMember(inboxId: inboxId).save(db) - let profile = DBMemberProfile( - conversationId: conversationId, - inboxId: inboxId, - name: nil, - avatar: nil, - metadata: metadata - ) - try profile.save(db) + func loadSelfMetadata() throws -> ProfileMetadata? { + try databaseManager.dbReader.read { db in + try DBMyProfile.filter(DBMyProfile.Columns.inboxId == inboxId).fetchOne(db)?.metadata } } } - @Test("Applies the closure on top of existing metadata and publishes the merge") + @Test("Applies the closure on top of existing metadata and persists the merge") func mergesExistingMetadata() async throws { let fixture = Fixture() - let conversationId = "convo-1" - let inboxId = "inbox-1" - try fixture.seedConversation(id: conversationId) - try fixture.seedMemberProfile( - conversationId: conversationId, - inboxId: inboxId, - metadata: ["connections": .string("existing-grants")] - ) + try fixture.seedSelfMetadata(["connections": .string("existing-grants")]) try await fixture.writer.updateMetadata( - conversationId: conversationId, - inboxId: inboxId + conversationId: "convo-1", + inboxId: fixture.inboxId ) { metadata in metadata["timezone"] = .string("Europe/Paris") } - let published = try #require(fixture.profileWriter.publishedMetadata.last) - #expect(published.conversationId == conversationId) - let metadata = try #require(published.metadata) + let metadata = try #require(try fixture.loadSelfMetadata()) #expect(metadata["connections"] == .string("existing-grants")) #expect(metadata["timezone"] == .string("Europe/Paris")) } - @Test("An empty merged map publishes nil") - func emptyMapPublishesNil() async throws { + @Test("An empty merged map clears the stored metadata") + func emptyMapClearsMetadata() async throws { let fixture = Fixture() - let conversationId = "convo-2" - let inboxId = "inbox-2" - try fixture.seedConversation(id: conversationId) try await fixture.writer.updateMetadata( - conversationId: conversationId, - inboxId: inboxId + conversationId: "convo-2", + inboxId: fixture.inboxId ) { _ in } - let published = try #require(fixture.profileWriter.publishedMetadata.last) - #expect(published.metadata == nil) + let metadata = try fixture.loadSelfMetadata() + #expect(metadata == nil) } @Test("Concurrent writes to different keys do not clobber each other") func concurrentWritesPreserveBothKeys() async throws { let fixture = Fixture() - let conversationId = "convo-3" - let inboxId = "inbox-3" - try fixture.seedConversation(id: conversationId) - try fixture.seedMemberProfile( - conversationId: conversationId, - inboxId: inboxId, - metadata: nil - ) - // First write lands the connections key, persisting it so the second - // write reads it back and the merge preserves it. Serialized by the - // writer's internal task chain. - try await fixture.writer.updateMetadata(conversationId: conversationId, inboxId: inboxId) { metadata in + // First write lands the connections key, persisting it to the self row + // so the second write reads it back and the merge preserves it. + // Serialized by the writer's internal task chain. + try await fixture.writer.updateMetadata(conversationId: "convo-3", inboxId: fixture.inboxId) { metadata in metadata["connections"] = .string("grants") } - // Persist what the first publish produced so the second read sees it, - // mirroring how MyProfileWriter saves to DBMemberProfile in production. - let firstMerged = try #require(fixture.profileWriter.publishedMetadata.last?.metadata) - try fixture.seedMemberProfile(conversationId: conversationId, inboxId: inboxId, metadata: firstMerged) - - try await fixture.writer.updateMetadata(conversationId: conversationId, inboxId: inboxId) { metadata in + try await fixture.writer.updateMetadata(conversationId: "convo-3", inboxId: fixture.inboxId) { metadata in metadata["timezone"] = .string("America/New_York") } - let published = try #require(fixture.profileWriter.publishedMetadata.last?.metadata) - #expect(published["connections"] == .string("grants")) - #expect(published["timezone"] == .string("America/New_York")) + let metadata = try #require(try fixture.loadSelfMetadata()) + #expect(metadata["connections"] == .string("grants")) + #expect(metadata["timezone"] == .string("America/New_York")) } } diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfilePublishStoreTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfilePublishStoreTests.swift new file mode 100644 index 000000000..ac1aad71c --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfilePublishStoreTests.swift @@ -0,0 +1,152 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Contract tests for `ProfilePublishStoreProtocol`, run against both +/// implementations. Covers source round-trip, `nextSeq` monotonicity, +/// `nextReadyJob` ordering and readiness, `activeJobs`/`jobs` ordering, +/// `earliestNextAttempt`, and supersede/delete. +@Suite("Profile publish store") +struct ProfilePublishStoreTests { + @Test("GRDB implementation satisfies the contract") + func grdbContract() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["conv-1", "conv-2"]) + let store = GRDBProfilePublishStore(databaseWriter: queue, databaseReader: queue) + try await runContract(store) + } + + @Test("in-memory implementation satisfies the contract") + func inMemoryContract() async throws { + try await runContract(InMemoryProfilePublishStore()) + } + + private func job(_ id: String, _ seq: Int64, _ conversationId: String, _ at: Date) -> DBProfilePublishJob { + DBProfilePublishJob( + id: id, + seq: seq, + conversationId: conversationId, + nextAttemptAt: at, + createdAt: at, + updatedAt: at + ) + } + + private func runContract(_ store: any ProfilePublishStoreProtocol) async throws { + let past1 = Date(timeIntervalSince1970: 100) + let past2 = Date(timeIntervalSince1970: 200) + let now = Date(timeIntervalSince1970: 1_000) + let future = Date(timeIntervalSince1970: 5_000) + + // Source round-trip. + let noSource = try await store.source(inboxId: "me") + #expect(noSource == nil) + try await store.setSource(DBProfileAvatarSource(inboxId: "me", plaintext: Data([1]), version: 1, updatedAt: past1)) + let v1 = try await store.source(inboxId: "me") + #expect(v1?.version == 1) + try await store.setSource(DBProfileAvatarSource(inboxId: "me", plaintext: Data([2]), version: 2, updatedAt: past2)) + let v2 = try await store.source(inboxId: "me") + #expect(v2?.version == 2) + + // bumpAvatarSource assigns the next version atomically. + let v3 = try await store.bumpAvatarSource(inboxId: "me", plaintext: Data([3]), updatedAt: past1) + #expect(v3 == 3) + let v4 = try await store.bumpAvatarSource(inboxId: "me", plaintext: Data([4]), updatedAt: past2) + #expect(v4 == 4) + let latestSource = try await store.source(inboxId: "me") + #expect(latestSource?.version == 4) + + // Enqueue jobs across conversations and readiness windows. + let seqStart = try await store.nextSeq() + #expect(seqStart == 1) + try await store.enqueue(job("A", 1, "conv-1", past1)) + try await store.enqueue(job("B", 2, "conv-1", future)) + try await store.enqueue(job("C", 3, "conv-2", past2)) + + let seqNext = try await store.nextSeq() + #expect(seqNext == 4) + let fetchedA = try await store.job(id: "A") + #expect(fetchedA != nil) + let fetchedMissing = try await store.job(id: "Z") + #expect(fetchedMissing == nil) + + // Ready = non-done with nextAttemptAt <= now, lowest seq first. + let ready = try await store.nextReadyJob(now: now) + #expect(ready?.id == "A") + let active = try await store.activeJobs() + #expect(active.map(\.id) == ["A", "B", "C"]) + let conv1 = try await store.jobs(conversationId: "conv-1") + #expect(conv1.map(\.id) == ["A", "B"]) + let earliest = try await store.earliestNextAttempt() + #expect(earliest == past1) + + // Marking A done removes it from ready/active and shifts earliest. + if var done = try await store.job(id: "A") { + done.state = .done + try await store.update(done) + } + let readyAfterDone = try await store.nextReadyJob(now: now) + #expect(readyAfterDone?.id == "C") + let activeAfterDone = try await store.activeJobs() + #expect(activeAfterDone.map(\.id) == ["B", "C"]) + let earliestAfterDone = try await store.earliestNextAttempt() + #expect(earliestAfterDone == past2) + + // Supersede drops conv-1 jobs older than seq 2 (deletes A). + try await store.supersedeOlderThan(conversationId: "conv-1", seq: 2) + let supersededA = try await store.job(id: "A") + #expect(supersededA == nil) + let activeAfterSupersede = try await store.activeJobs() + #expect(activeAfterSupersede.map(\.id) == ["B", "C"]) + + // Delete by conversation, then by id. + try await store.deleteJobs(conversationId: "conv-2") + let activeAfterConvDelete = try await store.activeJobs() + #expect(activeAfterConvDelete.map(\.id) == ["B"]) + try await store.deleteJob(id: "B") + let activeEmpty = try await store.activeJobs() + #expect(activeEmpty.isEmpty) + let readyNone = try await store.nextReadyJob(now: now) + #expect(readyNone == nil) + let earliestNone = try await store.earliestNextAttempt() + #expect(earliestNone == nil) + + // enqueueNext assigns a monotonic seq atomically and supersedes older + // jobs for the same conversation. + try await store.enqueueNext { seq in + DBProfilePublishJob(id: "E", seq: seq, conversationId: "conv-1", nextAttemptAt: past1, createdAt: past1, updatedAt: past1) + } + try await store.enqueueNext { seq in + DBProfilePublishJob(id: "F", seq: seq, conversationId: "conv-1", nextAttemptAt: past1, createdAt: past1, updatedAt: past1) + } + let conv1Next = try await store.jobs(conversationId: "conv-1") + // Older E superseded by newer F (same conversation). + #expect(conv1Next.map(\.id) == ["F"]) + let fJob = try await store.job(id: "F") + #expect((fJob?.seq ?? 0) > 0) + + // reclaimStalledJobs returns an in-flight (uploading) job to pending so + // it is ready again; nextReadyJob excludes it while uploading. + if var uploading = try await store.job(id: "F") { + uploading.state = .uploading + try await store.update(uploading) + } + let readyWhileUploading = try await store.nextReadyJob(now: now) + #expect(readyWhileUploading == nil) + try await store.reclaimStalledJobs() + let readyAfterReclaim = try await store.nextReadyJob(now: now) + #expect(readyAfterReclaim?.id == "F") + + // clearSource and deleteAll. + try await store.clearSource(inboxId: "me") + let clearedSource = try await store.source(inboxId: "me") + #expect(clearedSource == nil) + try await store.setSource(DBProfileAvatarSource(inboxId: "me", plaintext: Data([3]), version: 3, updatedAt: past1)) + try await store.enqueue(job("D", 5, "conv-1", past1)) + try await store.deleteAll() + let sourceAfterDeleteAll = try await store.source(inboxId: "me") + #expect(sourceAfterDeleteAll == nil) + let jobsAfterDeleteAll = try await store.activeJobs() + #expect(jobsAfterDeleteAll.isEmpty) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfilePublisherTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfilePublisherTests.swift new file mode 100644 index 000000000..48339ee27 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfilePublisherTests.swift @@ -0,0 +1,303 @@ +@testable import ConvosCore +import Foundation +import Testing + +private struct RecordedSend: Sendable { + let name: String? + let avatarURL: String? + let conversationId: String +} + +/// Test double for the XMTP/upload seam. Records uploads and sends, and can be +/// configured to fail a number of upload/send attempts before succeeding. +private actor FakeProfilePublishSession: ProfilePublishSession { + nonisolated let inboxId: String + private let imageKeys: [String: Data] + private var uploadFailuresRemaining: Int + private var sendFailuresRemaining: Int + private(set) var uploadAttempts: Int = 0 + private(set) var uploads: [String] = [] + private(set) var sends: [RecordedSend] = [] + + init( + inboxId: String, + imageKeys: [String: Data], + uploadFailures: Int = 0, + sendFailures: Int = 0 + ) { + self.inboxId = inboxId + self.imageKeys = imageKeys + self.uploadFailuresRemaining = uploadFailures + self.sendFailuresRemaining = sendFailures + } + + func imageKey(conversationId: String) -> Data? { imageKeys[conversationId] } + + nonisolated func encrypt(_ plaintext: Data, groupKey: Data) -> EncryptedAvatarPayload { + EncryptedAvatarPayload(ciphertext: plaintext, salt: Data(repeating: 9, count: 32), nonce: Data(repeating: 8, count: 12)) + } + + func upload(_ ciphertext: Data, filename: String) throws -> String { + uploadAttempts += 1 + if uploadFailuresRemaining > 0 { + uploadFailuresRemaining -= 1 + throw FakeSessionError.upload + } + uploads.append(filename) + return "https://uploads/\(uploads.count)" + } + + func sendProfileUpdate(name: String?, metadata: ProfileMetadata?, avatar: PublishedAvatar?, conversationId: String) throws { + if sendFailuresRemaining > 0 { + sendFailuresRemaining -= 1 + throw FakeSessionError.send + } + sends.append(RecordedSend(name: name, avatarURL: avatar?.url, conversationId: conversationId)) + } +} + +private enum FakeSessionError: Error { + case upload + case send +} + +/// Monotonic clock the tests advance by hand for deterministic backoff retries. +private final class TestClock: @unchecked Sendable { + private let lock = NSLock() + private var value: Date + + init(_ start: Date) { value = start } + + var current: Date { + lock.lock() + defer { lock.unlock() } + return value + } + + func advance(by interval: TimeInterval) { + lock.lock() + value = value.addingTimeInterval(interval) + lock.unlock() + } +} + +@Suite("ProfilePublisher") +struct ProfilePublisherTests { + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + + private func makePublisher( + publishStore: any ProfilePublishStoreProtocol, + profileStore: any ProfileStoreProtocol, + selfProfileStore: any SelfProfileStoreProtocol, + clock: TestClock, + conversationLocalStateWriter: any ConversationLocalStateWriterProtocol = MockConversationLocalStateWriter() + ) -> ProfilePublisher { + ProfilePublisher( + publishStore: publishStore, + profileStore: profileStore, + selfProfileStore: selfProfileStore, + conversationLocalStateWriter: conversationLocalStateWriter, + selfInboxIdProvider: { "me" }, + now: { clock.current }, + backoff: PublishBackoff(base: 1, cap: 5, jitterFraction: 0) + ) + } + + @Test("publish enqueues, encrypts/uploads/sends per conversation, and writes local slots") + func publishAvatar() async throws { + let publishStore = InMemoryProfilePublishStore() + let profileStore = InMemoryProfileStore() + let clock = TestClock(Date(timeIntervalSince1970: 1_000)) + let publisher = makePublisher(publishStore: publishStore, profileStore: profileStore, selfProfileStore: InMemorySelfProfileStore(), clock: clock) + let session = FakeProfilePublishSession(inboxId: "me", imageKeys: ["c1": key, "c2": key]) + await publisher.attach(session: session) + + try await publisher.updateAvatarSource(Data([1, 2, 3])) + try await publisher.publishConversation("c1") + try await publisher.publishConversation("c2") + + let sends = await session.sends + #expect(sends.count == 2) + #expect(Set(sends.map(\.conversationId)) == ["c1", "c2"]) + #expect(sends.allSatisfy { $0.avatarURL != nil }) + let uploadAttempts = await session.uploadAttempts + #expect(uploadAttempts == 2) + + let slot = try await profileStore.avatar(inboxId: "me", conversationId: "c1") + #expect(slot?.url != nil) + let remaining = try await publishStore.activeJobs() + #expect(remaining.isEmpty) + } + + @Test("a failed upload is retried with backoff and eventually succeeds") + func retriesFailedUpload() async throws { + let publishStore = InMemoryProfilePublishStore() + let clock = TestClock(Date(timeIntervalSince1970: 1_000)) + let publisher = makePublisher(publishStore: publishStore, profileStore: InMemoryProfileStore(), selfProfileStore: InMemorySelfProfileStore(), clock: clock) + let session = FakeProfilePublishSession(inboxId: "me", imageKeys: ["c1": key], uploadFailures: 1) + await publisher.attach(session: session) + + try await publisher.updateAvatarSource(Data([1])) + try await publisher.publishConversation("c1") + let sendsAfterFailure = await session.sends + #expect(sendsAfterFailure.isEmpty) + let attemptsAfterFailure = await session.uploadAttempts + #expect(attemptsAfterFailure == 1) + + clock.advance(by: 2) + await publisher.drainReadyJobs() + + let attempts = await session.uploadAttempts + #expect(attempts == 2) + let sends = await session.sends + #expect(sends.count == 1) + let remaining = try await publishStore.activeJobs() + #expect(remaining.isEmpty) + } + + @Test("a job for a missing conversation is dropped") + func dropsMissingConversation() async throws { + let publishStore = InMemoryProfilePublishStore() + let clock = TestClock(Date(timeIntervalSince1970: 1_000)) + let publisher = makePublisher(publishStore: publishStore, profileStore: InMemoryProfileStore(), selfProfileStore: InMemorySelfProfileStore(), clock: clock) + let session = FakeProfilePublishSession(inboxId: "me", imageKeys: [:]) + await publisher.attach(session: session) + + try await publisher.updateAvatarSource(Data([1])) + try await publisher.publishConversation("c1") + + let sends = await session.sends + #expect(sends.isEmpty) + let remaining = try await publishStore.activeJobs() + #expect(remaining.isEmpty) + } + + @Test("a job pinned to a superseded source version is dropped without uploading") + func dropsStaleSourceVersion() async throws { + let publishStore = InMemoryProfilePublishStore() + let clock = TestClock(Date(timeIntervalSince1970: 1_000)) + try await publishStore.setSource(DBProfileAvatarSource(inboxId: "me", plaintext: Data([9]), version: 2, updatedAt: clock.current)) + let seq = try await publishStore.nextSeq() + try await publishStore.enqueue(DBProfilePublishJob( + id: "j1", seq: seq, conversationId: "c1", sourceVersion: 1, hasAvatar: true, + nextAttemptAt: clock.current, createdAt: clock.current, updatedAt: clock.current + )) + let publisher = makePublisher(publishStore: publishStore, profileStore: InMemoryProfileStore(), selfProfileStore: InMemorySelfProfileStore(), clock: clock) + let session = FakeProfilePublishSession(inboxId: "me", imageKeys: ["c1": key]) + + await publisher.attach(session: session) + + let sends = await session.sends + #expect(sends.isEmpty) + let attempts = await session.uploadAttempts + #expect(attempts == 0) + let remaining = try await publishStore.activeJobs() + #expect(remaining.isEmpty) + } + + @Test("a name-only publish re-sends the existing avatar rather than clearing it") + func nameOnlyResendsAvatar() async throws { + let publishStore = InMemoryProfilePublishStore() + let profileStore = InMemoryProfileStore() + let clock = TestClock(Date(timeIntervalSince1970: 1_000)) + try await profileStore.saveAvatar(DBProfileAvatar( + inboxId: "me", conversationId: "c1", url: "existing", salt: salt, nonce: nonce, + encryptionKey: key, profileSource: .profileUpdate, updatedAt: Date(timeIntervalSince1970: 1) + )) + let publisher = makePublisher(publishStore: publishStore, profileStore: profileStore, selfProfileStore: InMemorySelfProfileStore(), clock: clock) + let session = FakeProfilePublishSession(inboxId: "me", imageKeys: ["c1": key]) + await publisher.attach(session: session) + + try await publisher.publishConversation("c1") + + let sends = await session.sends + #expect(sends.count == 1) + #expect(sends.first?.avatarURL == "existing") + let attempts = await session.uploadAttempts + #expect(attempts == 0) + } + + @Test("attach drains jobs left over from a previous run") + func resumesLeftoverJobs() async throws { + let publishStore = InMemoryProfilePublishStore() + let clock = TestClock(Date(timeIntervalSince1970: 1_000)) + let seq = try await publishStore.nextSeq() + try await publishStore.enqueue(DBProfilePublishJob( + id: "j1", seq: seq, conversationId: "c1", hasAvatar: false, + nextAttemptAt: clock.current, createdAt: clock.current, updatedAt: clock.current + )) + let publisher = makePublisher(publishStore: publishStore, profileStore: InMemoryProfileStore(), selfProfileStore: InMemorySelfProfileStore(), clock: clock) + let session = FakeProfilePublishSession(inboxId: "me", imageKeys: ["c1": key]) + + await publisher.attach(session: session) + + let sends = await session.sends + #expect(sends.count == 1) + let remaining = try await publishStore.activeJobs() + #expect(remaining.isEmpty) + } + + @Test("a successful publish stamps publishedProfileUpdatedAt for the conversation") + func stampsConversationOnSuccessfulPublish() async throws { + let publishStore = InMemoryProfilePublishStore() + let clock = TestClock(Date(timeIntervalSince1970: 1_000)) + let selfProfileStore = InMemorySelfProfileStore() + let updatedAt = Date(timeIntervalSince1970: 500) + try await selfProfileStore.save(DBMyProfile(inboxId: "me", name: "Ziggy", updatedAt: updatedAt)) + let localStateWriter = MockConversationLocalStateWriter() + let publisher = makePublisher( + publishStore: publishStore, profileStore: InMemoryProfileStore(), + selfProfileStore: selfProfileStore, clock: clock, conversationLocalStateWriter: localStateWriter + ) + let session = FakeProfilePublishSession(inboxId: "me", imageKeys: ["c1": key]) + await publisher.attach(session: session) + + try await publisher.publishConversation("c1") + + let sends = await session.sends + #expect(sends.count == 1) + let stamped = localStateWriter.publishedProfileUpdatedAtStates["c1"] ?? nil + #expect(stamped == updatedAt) + } + + @Test("a job left uploading by an interrupted drain is reclaimed and processed") + func reclaimsStalledUploadingJob() async throws { + let publishStore = InMemoryProfilePublishStore() + let clock = TestClock(Date(timeIntervalSince1970: 1_000)) + // Seed a job stuck in-flight, as if a prior drain died mid-upload. + let seq = try await publishStore.nextSeq() + try await publishStore.enqueue(DBProfilePublishJob( + id: "stuck", seq: seq, conversationId: "c1", hasAvatar: false, state: .uploading, + nextAttemptAt: clock.current, createdAt: clock.current, updatedAt: clock.current + )) + let publisher = makePublisher(publishStore: publishStore, profileStore: InMemoryProfileStore(), selfProfileStore: InMemorySelfProfileStore(), clock: clock) + let session = FakeProfilePublishSession(inboxId: "me", imageKeys: ["c1": key]) + + await publisher.attach(session: session) + + let sends = await session.sends + #expect(sends.count == 1) + let remaining = try await publishStore.activeJobs() + #expect(remaining.isEmpty) + } + + @Test("seedAvatarSourceIfAbsent seeds only when no source exists") + func seedAvatarSourceIfAbsentIsIdempotent() async throws { + let publishStore = InMemoryProfilePublishStore() + let clock = TestClock(Date(timeIntervalSince1970: 1_000)) + let publisher = makePublisher(publishStore: publishStore, profileStore: InMemoryProfileStore(), selfProfileStore: InMemorySelfProfileStore(), clock: clock) + + try await publisher.seedAvatarSourceIfAbsent(Data([1, 2, 3])) + let seeded = try await publishStore.source(inboxId: "me") + #expect(seeded?.version == 1) + #expect(seeded?.plaintext == Data([1, 2, 3])) + + // Second call must not overwrite an existing source. + try await publisher.seedAvatarSourceIfAbsent(Data([9, 9])) + let unchanged = try await publishStore.source(inboxId: "me") + #expect(unchanged?.version == 1) + #expect(unchanged?.plaintext == Data([1, 2, 3])) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileSnapshotBuilderSelfTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileSnapshotBuilderSelfTests.swift new file mode 100644 index 000000000..dc7d644d2 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileSnapshotBuilderSelfTests.swift @@ -0,0 +1,77 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Focused coverage for the self-union in `ProfileSnapshotBuilder.fetchDBProfiles`: +/// self identity lives in `myProfile` (not `profile`), so the outbound roster +/// must fold it in or a sender advertises no identity for itself and joiners +/// render the creator as "Somebody". +/// +/// Inbox ids must be valid hex: the snapshot's `MemberProfile(inboxIdString:)` +/// decodes the id via `Data(hexString:)` and drops non-hex entries. +@Suite("ProfileSnapshotBuilder self union") +struct ProfileSnapshotBuilderSelfTests { + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + private let meId = "abcdef01" + private let aliceId = "abcdef02" + + @Test("fetchDBProfiles folds in the self identity from myProfile") + func fetchDBProfilesUnionsSelf() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + let time = Date(timeIntervalSince1970: 1) + try await queue.write { db in + try DBProfile(inboxId: aliceId, name: "Alice", profileSource: .profileUpdate, updatedAt: time).save(db) + try DBMyProfile(inboxId: meId, name: "Me").save(db) + try DBProfileAvatar( + inboxId: meId, conversationId: "c1", url: "u", salt: salt, nonce: nonce, + encryptionKey: key, profileSource: .profileUpdate, updatedAt: time + ).save(db) + } + + let profiles = try await ProfileSnapshotBuilder.fetchDBProfiles( + queue, conversationId: "c1", memberInboxIds: [aliceId, meId] + ) + + let me = profiles.first { $0.inboxIdString == meId } + #expect(me != nil) + #expect(me?.name == "Me") + #expect(me?.encryptedImage.url == "u") + #expect(profiles.contains { $0.inboxIdString == aliceId }) + } + + @Test("fetchDBProfiles prefers the canonical profile row over myProfile") + func fetchDBProfilesPrefersCanonical() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + let time = Date(timeIntervalSince1970: 1) + try await queue.write { db in + try DBProfile(inboxId: meId, name: "Canonical", profileSource: .profileUpdate, updatedAt: time).save(db) + try DBMyProfile(inboxId: meId, name: "Self").save(db) + } + + let profiles = try await ProfileSnapshotBuilder.fetchDBProfiles( + queue, conversationId: "c1", memberInboxIds: [meId] + ) + + let me = profiles.first { $0.inboxIdString == meId } + #expect(me?.name == "Canonical") + } + + @Test("fetchDBProfiles omits self when it is not a member") + func fetchDBProfilesOmitsNonMemberSelf() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + let time = Date(timeIntervalSince1970: 1) + try await queue.write { db in + try DBProfile(inboxId: aliceId, name: "Alice", profileSource: .profileUpdate, updatedAt: time).save(db) + try DBMyProfile(inboxId: meId, name: "Me").save(db) + } + + let profiles = try await ProfileSnapshotBuilder.fetchDBProfiles( + queue, conversationId: "c1", memberInboxIds: [aliceId] + ) + + #expect(!profiles.contains { $0.inboxIdString == meId }) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileStoreTestSupport.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileStoreTestSupport.swift new file mode 100644 index 000000000..309d82a15 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileStoreTestSupport.swift @@ -0,0 +1,40 @@ +@testable import ConvosCore +import Foundation +import GRDB + +/// Shared helpers for the profile store tests: build an in-memory database with +/// the Profile-table schema (plus a minimal `conversation` table the avatar and +/// job foreign keys reference) and seed conversation rows. +enum ProfileStoreTestSupport { + static func makeSchema(_ db: Database) throws { + try db.create(table: "conversation") { t in + t.column("id", .text).notNull().primaryKey() + } + try db.create(table: "myProfile") { t in + t.column("inboxId", .text).notNull().primaryKey() + t.column("name", .text) + t.column("imageData", .blob) + t.column("imageAssetIdentifier", .text) + t.column("imageContentDigest", .text) + t.column("metadata", .jsonText) + t.column("updatedAt", .datetime).notNull() + } + try SharedDatabaseMigrator.createProfileTables(db) + try SharedDatabaseMigrator.createProfileAvatarLatestView(db) + } + + static func seedConversations(_ db: Database, ids: [String]) throws { + for id in ids { + try db.execute(sql: "INSERT INTO conversation (id) VALUES (?)", arguments: [id]) + } + } + + static func makeQueue(conversations: [String] = []) throws -> DatabaseQueue { + let queue = try DatabaseQueue() + try queue.write { db in + try makeSchema(db) + try seedConversations(db, ids: conversations) + } + return queue + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileStoreTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileStoreTests.swift new file mode 100644 index 000000000..83c5a3dc7 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileStoreTests.swift @@ -0,0 +1,65 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Contract tests for `ProfileStoreProtocol`, run against both the GRDB-backed +/// and in-memory implementations so the two stay behaviorally identical. +@Suite("Profile store") +struct ProfileStoreTests { + @Test("GRDB implementation satisfies the contract") + func grdbContract() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["conv-1", "conv-2"]) + let store = GRDBProfileStore(databaseWriter: queue, databaseReader: queue) + try await runContract(store) + } + + @Test("in-memory implementation satisfies the contract") + func inMemoryContract() async throws { + try await runContract(InMemoryProfileStore()) + } + + private func runContract(_ store: any ProfileStoreProtocol) async throws { + let t = Date(timeIntervalSince1970: 1) + try await store.saveIdentity(DBProfile(inboxId: "inbox-1", name: "Alice", profileSource: .profileUpdate, updatedAt: t)) + try await store.saveIdentity(DBProfile(inboxId: "inbox-2", name: "Bob", profileSource: .profileUpdate, updatedAt: t)) + + let alice = try await store.identity(inboxId: "inbox-1") + #expect(alice?.name == "Alice") + let both = try await store.identities(inboxIds: ["inbox-1", "inbox-2"]) + #expect(both.count == 2) + let allIdentities = try await store.allIdentities() + #expect(allIdentities.count == 2) + + try await store.saveAvatar(DBProfileAvatar(inboxId: "inbox-1", conversationId: "conv-1", url: "a", profileSource: .profileUpdate, updatedAt: t)) + try await store.saveAvatar(DBProfileAvatar(inboxId: "inbox-1", conversationId: "conv-2", url: "b", profileSource: .profileUpdate, updatedAt: t)) + try await store.saveAvatar(DBProfileAvatar(inboxId: "inbox-2", conversationId: "conv-1", url: "c", profileSource: .profileUpdate, updatedAt: t)) + + let oneAvatar = try await store.avatar(inboxId: "inbox-1", conversationId: "conv-1") + #expect(oneAvatar?.url == "a") + let aliceAvatars = try await store.avatars(inboxId: "inbox-1") + #expect(aliceAvatars.count == 2) + let batchAvatars = try await store.avatars(inboxIds: ["inbox-1", "inbox-2"]) + #expect(batchAvatars.count == 3) + let allAvatars = try await store.allAvatars() + #expect(allAvatars.count == 3) + + try await store.deleteAvatars(conversationId: "conv-1") + let afterConvDelete = try await store.allAvatars() + #expect(afterConvDelete.count == 1) + let survivor = try await store.avatar(inboxId: "inbox-1", conversationId: "conv-2") + #expect(survivor?.url == "b") + + try await store.deleteProfile(inboxId: "inbox-1") + let goneIdentity = try await store.identity(inboxId: "inbox-1") + #expect(goneIdentity == nil) + let remainingIdentities = try await store.allIdentities() + #expect(remainingIdentities.count == 1) + let remainingAvatars = try await store.allAvatars() + #expect(remainingAvatars.isEmpty) + + try await store.deleteAll() + let empty = try await store.allIdentities() + #expect(empty.isEmpty) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileTablesMigrationTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileTablesMigrationTests.swift new file mode 100644 index 000000000..e34fb2f37 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileTablesMigrationTests.swift @@ -0,0 +1,209 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Guards `SharedDatabaseMigrator.createProfileTables`, the additive schema for +/// the canonical Profile-table identity model (see +/// docs/plans/2026-06-29-profile-table-implementation.md). +/// +/// The migrator's `createMigrator()` is private and DEBUG enables +/// `eraseDatabaseOnSchemaChange`, so these tests build the conversation table the +/// new tables reference, call the extracted static helper directly, then seed and +/// read rows (mirrors `DropRevealColumnsMigrationTests`). +@Suite("Create profile tables migration", .serialized) +struct ProfileTablesMigrationTests { + /// The new avatar/job tables carry foreign keys to `conversation`, so a + /// minimal conversation table must exist before `createProfileTables` runs. + private func makeSchema(_ db: Database) throws { + try db.create(table: "conversation") { t in + t.column("id", .text).notNull().primaryKey() + } + try SharedDatabaseMigrator.createProfileTables(db) + } + + private func insertConversation(_ db: Database, id: String) throws { + try db.execute(sql: "INSERT INTO conversation (id) VALUES (?)", arguments: [id]) + } + + @Test("creates all four tables with expected columns and primary keys") + func createsTables() throws { + let dbQueue = try DatabaseQueue() + try dbQueue.write { db in + try self.makeSchema(db) + } + + try dbQueue.read { db in + #expect(try db.tableExists("profile")) + #expect(try db.tableExists("profileAvatar")) + #expect(try db.tableExists("profileAvatarSource")) + #expect(try db.tableExists("profilePublishJob")) + + let profileColumns = try db.columns(in: "profile").map(\.name) + let expectedProfileColumns = [ + "inboxId", "name", "memberKind", "metadata", + "profileSource", "avatarContentDigest", "updatedAt" + ] + #expect(expectedProfileColumns.allSatisfy(profileColumns.contains)) + + let avatarColumns = try db.columns(in: "profileAvatar").map(\.name) + let expectedAvatarColumns = [ + "inboxId", "conversationId", "url", "salt", "nonce", + "encryptionKey", "profileSource", "contentDigest", "updatedAt" + ] + #expect(expectedAvatarColumns.allSatisfy(avatarColumns.contains)) + + let jobColumns = try db.columns(in: "profilePublishJob").map(\.name) + let expectedJobColumns = [ + "id", "seq", "conversationId", "sourceVersion", "hasAvatar", + "state", "ciphertext", "salt", "nonce", "groupKey", "filename", + "uploadedURL", "attemptCount", "nextAttemptAt", "lastError", + "createdAt", "updatedAt" + ] + #expect(expectedJobColumns.allSatisfy(jobColumns.contains)) + + let avatarPK = try db.primaryKey("profileAvatar") + #expect(avatarPK.columns == ["inboxId", "conversationId"]) + } + } + + @Test("DBProfile round-trips, including metadata and member kind") + func profileRoundTrips() throws { + let dbQueue = try DatabaseQueue() + let updatedAt = Date(timeIntervalSince1970: 1_000_000) + try dbQueue.write { db in + try self.makeSchema(db) + let profile = DBProfile( + inboxId: "inbox-1", + name: "Alice", + memberKind: .verifiedConvos, + metadata: ["templateId": .string("tmpl-1")], + profileSource: .profileUpdate, + updatedAt: updatedAt + ) + try profile.save(db) + } + + try dbQueue.read { db in + let fetched = try DBProfile.fetchOne(db, inboxId: "inbox-1") + #expect(fetched?.name == "Alice") + #expect(fetched?.memberKind == .verifiedConvos) + #expect(fetched?.profileSource == .profileUpdate) + #expect(fetched?.metadata?["templateId"]?.stringValue == "tmpl-1") + #expect(fetched?.avatarContentDigest == nil) + #expect(fetched?.updatedAt == updatedAt) + } + } + + @Test("DBProfileAvatar round-trips by composite key") + func avatarRoundTrips() throws { + let dbQueue = try DatabaseQueue() + try dbQueue.write { db in + try self.makeSchema(db) + try self.insertConversation(db, id: "conv-1") + let avatar = DBProfileAvatar( + inboxId: "inbox-1", + conversationId: "conv-1", + url: "https://example.com/a.enc", + salt: Data(repeating: 1, count: 32), + nonce: Data(repeating: 2, count: 12), + encryptionKey: Data(repeating: 3, count: 32), + profileSource: .profileSnapshot, + updatedAt: Date(timeIntervalSince1970: 1) + ) + try avatar.save(db) + } + + try dbQueue.read { db in + let fetched = try DBProfileAvatar.fetchOne(db, inboxId: "inbox-1", conversationId: "conv-1") + #expect(fetched?.url == "https://example.com/a.enc") + #expect(fetched?.salt?.count == 32) + #expect(fetched?.nonce?.count == 12) + #expect(fetched?.encryptionKey?.count == 32) + #expect(fetched?.profileSource == .profileSnapshot) + #expect(fetched?.hasValidEncryptedAvatar == true) + } + } + + @Test("DBProfileAvatarSource round-trips") + func sourceRoundTrip() throws { + let dbQueue = try DatabaseQueue() + try dbQueue.write { db in + try self.makeSchema(db) + try DBProfileAvatarSource( + inboxId: "me", + plaintext: Data(repeating: 9, count: 16), + version: 3, + updatedAt: Date(timeIntervalSince1970: 2) + ).save(db) + } + + try dbQueue.read { db in + let source = try DBProfileAvatarSource.fetchOne(db, inboxId: "me") + #expect(source?.version == 3) + #expect(source?.plaintext.count == 16) + } + } + + @Test("DBProfilePublishJob round-trips with state default and pinned source version") + func publishJobRoundTrips() throws { + let dbQueue = try DatabaseQueue() + try dbQueue.write { db in + try self.makeSchema(db) + try self.insertConversation(db, id: "conv-1") + let job = DBProfilePublishJob( + id: "job-1", + seq: 1, + conversationId: "conv-1", + sourceVersion: 7, + hasAvatar: true, + nextAttemptAt: Date(timeIntervalSince1970: 5), + createdAt: Date(timeIntervalSince1970: 5), + updatedAt: Date(timeIntervalSince1970: 5) + ) + try job.save(db) + } + + try dbQueue.read { db in + let fetched = try DBProfilePublishJob.fetchOne(db, id: "job-1") + #expect(fetched?.state == .pending) + #expect(fetched?.sourceVersion == 7) + #expect(fetched?.hasAvatar == true) + #expect(fetched?.attemptCount == 0) + } + } + + @Test("deleting a conversation cascades to its avatar slots and publish jobs") + func conversationDeleteCascades() throws { + let dbQueue = try DatabaseQueue() + try dbQueue.write { db in + try self.makeSchema(db) + try self.insertConversation(db, id: "conv-1") + try DBProfileAvatar( + inboxId: "inbox-1", + conversationId: "conv-1", + profileSource: .profileUpdate, + updatedAt: Date(timeIntervalSince1970: 1) + ).save(db) + try DBProfilePublishJob( + id: "job-1", + seq: 1, + conversationId: "conv-1", + nextAttemptAt: Date(timeIntervalSince1970: 1), + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 1) + ).save(db) + } + + try dbQueue.write { db in + try db.execute(sql: "DELETE FROM conversation WHERE id = ?", arguments: ["conv-1"]) + } + + try dbQueue.read { db in + let avatar = try DBProfileAvatar.fetchOne(db, inboxId: "inbox-1", conversationId: "conv-1") + #expect(avatar == nil) + let job = try DBProfilePublishJob.fetchOne(db, id: "job-1") + #expect(job == nil) + } + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfileValueTypeTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfileValueTypeTests.swift new file mode 100644 index 000000000..b10ccdcd9 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfileValueTypeTests.swift @@ -0,0 +1,73 @@ +@testable import ConvosCore +import Foundation +import Testing + +@Suite("Avatar value type") +struct AvatarTests { + private let updatedAt: Date = .init(timeIntervalSince1970: 1) + + @Test("nil or empty url yields no avatar") + func emptyURLIsNil() { + #expect(Avatar.from(url: nil, salt: nil, nonce: nil, key: nil, updatedAt: updatedAt) == nil) + #expect(Avatar.from(url: "", salt: nil, nonce: nil, key: nil, updatedAt: updatedAt) == nil) + } + + @Test("url with no crypto material is plain") + func plainAvatar() { + let avatar = Avatar.from(url: "https://e.com/a.jpg", salt: nil, nonce: nil, key: nil, updatedAt: updatedAt) + #expect(avatar == .plain(url: "https://e.com/a.jpg", updatedAt: updatedAt)) + #expect(avatar?.isEncrypted == false) + #expect(avatar?.url == "https://e.com/a.jpg") + } + + @Test("correctly sized crypto material yields encrypted") + func encryptedAvatar() { + let salt = Data(repeating: 1, count: 32) + let nonce = Data(repeating: 2, count: 12) + let key = Data(repeating: 3, count: 32) + let avatar = Avatar.from(url: "https://e.com/a.enc", salt: salt, nonce: nonce, key: key, updatedAt: updatedAt) + #expect(avatar == .encrypted(url: "https://e.com/a.enc", salt: salt, nonce: nonce, key: key, updatedAt: updatedAt)) + #expect(avatar?.isEncrypted == true) + } + + @Test("wrong-sized crypto material falls back to plain") + func wrongSizedCryptoFallsBackToPlain() { + let badSalt = Data(repeating: 1, count: 16) + let nonce = Data(repeating: 2, count: 12) + let key = Data(repeating: 3, count: 32) + let avatar = Avatar.from(url: "https://e.com/a.enc", salt: badSalt, nonce: nonce, key: key, updatedAt: updatedAt) + #expect(avatar?.isEncrypted == false) + #expect(avatar?.url == "https://e.com/a.enc") + } + + @Test("partial crypto material falls back to plain") + func partialCryptoFallsBackToPlain() { + let salt = Data(repeating: 1, count: 32) + let avatar = Avatar.from(url: "https://e.com/a.enc", salt: salt, nonce: nil, key: nil, updatedAt: updatedAt) + #expect(avatar?.isEncrypted == false) + } +} + +@Suite("ProfileSource precedence") +struct ProfileSourceTests { + @Test("orders from contact lowest to profileUpdate highest") + func ordering() { + #expect(ProfileSource.contact < .appData) + #expect(ProfileSource.appData < .profileSnapshot) + #expect(ProfileSource.profileSnapshot < .profileUpdate) + #expect(ProfileSource.profileUpdate > .contact) + } + + @Test("max of a set is the highest precedence") + func maxPrecedence() { + let sources: [ProfileSource] = [.contact, .profileSnapshot, .appData] + #expect(sources.max() == .profileSnapshot) + } + + @Test("round-trips through its raw value") + func rawValueRoundTrip() { + for source in ProfileSource.allCases { + #expect(ProfileSource(rawValue: source.rawValue) == source) + } + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/ProfilesRepositoryTests.swift b/ConvosCore/Tests/ConvosCoreTests/ProfilesRepositoryTests.swift new file mode 100644 index 000000000..a2fecca4f --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/ProfilesRepositoryTests.swift @@ -0,0 +1,337 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +@Suite("ProfilesRepository") +struct ProfilesRepositoryTests { + private let salt = Data(repeating: 1, count: 32) + private let nonce = Data(repeating: 2, count: 12) + private let key = Data(repeating: 3, count: 32) + + private func makeRepository( + profileStore: any ProfileStoreProtocol = InMemoryProfileStore(), + selfProfileStore: any SelfProfileStoreProtocol = InMemorySelfProfileStore(), + publishStore: any ProfilePublishStoreProtocol = InMemoryProfilePublishStore(), + database: (any DatabaseWriter)? = nil, + selfInboxId: String = "me" + ) throws -> ProfilesRepository { + let db = try database ?? DatabaseQueue() + return ProfilesRepository( + profileStore: profileStore, + selfProfileStore: selfProfileStore, + publishStore: publishStore, + databaseReader: db, + conversationLocalStateWriter: ConversationLocalStateWriter(databaseWriter: db), + selfInboxIdProvider: { selfInboxId } + ) + } + + private func setAvatar(_ url: String) -> IncomingAvatar { + .set(url: url, salt: salt, nonce: nonce, key: key) + } + + private func event( + inboxId: String, + conversationId: String, + name: String?, + avatar: IncomingAvatar, + source: ProfileSource = .profileUpdate, + sentAt: Date + ) -> ProfileDomainEvent { + ProfileDomainEvent( + inboxId: inboxId, + conversationId: conversationId, + source: source, + identity: IncomingIdentity(name: name), + avatar: avatar, + sentAt: sentAt + ) + } + + @Test("apply creates identity and avatar, readable via profile()") + func applyCreatesProfile() async throws { + let repository = try makeRepository() + await repository.warmUp() + await repository.apply(event( + inboxId: "alice", conversationId: "c1", name: "Alice", + avatar: setAvatar("u"), sentAt: Date(timeIntervalSince1970: 1) + )) + + let profile = await repository.profile(inboxId: "alice") + #expect(profile.name == "Alice") + #expect(profile.displayAvatar(for: "c1")?.url == "u") + } + + @Test("apply ignores events authored by the current user") + func selfEchoIgnored() async throws { + let repository = try makeRepository(selfInboxId: "me") + await repository.warmUp() + await repository.apply(event( + inboxId: "me", conversationId: "c1", name: "Myself", + avatar: .silent, sentAt: Date(timeIntervalSince1970: 1) + )) + + let profile = await repository.profile(inboxId: "me") + #expect(profile.name == nil) + } + + @Test("apply respects merge precedence end to end") + func applyRespectsPrecedence() async throws { + let repository = try makeRepository() + await repository.warmUp() + await repository.apply(event( + inboxId: "alice", conversationId: "c1", name: "Authoritative", + avatar: .silent, source: .profileUpdate, sentAt: Date(timeIntervalSince1970: 2) + )) + // A lower-source, older event must not overwrite the name. + await repository.apply(event( + inboxId: "alice", conversationId: "c1", name: "Snapshot", + avatar: .silent, source: .profileSnapshot, sentAt: Date(timeIntervalSince1970: 1) + )) + + let profile = await repository.profile(inboxId: "alice") + #expect(profile.name == "Authoritative") + } + + @Test("warmUp loads persisted rows into the cache") + func warmUpLoads() async throws { + let profileStore = InMemoryProfileStore() + try await profileStore.saveIdentity( + DBProfile(inboxId: "bob", name: "Bob", profileSource: .profileUpdate, updatedAt: Date(timeIntervalSince1970: 1)) + ) + let repository = try makeRepository(profileStore: profileStore) + await repository.warmUp() + + let profile = await repository.profile(inboxId: "bob") + #expect(profile.name == "Bob") + } + + @Test("updateSelfProfile persists and is reflected in selfProfile()") + func updateSelf() async throws { + let selfStore = InMemorySelfProfileStore() + let repository = try makeRepository(selfProfileStore: selfStore) + await repository.warmUp() + try await repository.updateSelfProfile(SelfProfileEdit(name: .set("Me"))) + + let profile = await repository.selfProfile() + #expect(profile?.name == "Me") + let persisted = try await selfStore.load() + #expect(persisted?.name == "Me") + } + + @Test("displayAvatar falls back to the newest slot without a conversation match") + func displayAvatarFallback() async throws { + let repository = try makeRepository() + await repository.warmUp() + await repository.apply(event( + inboxId: "alice", conversationId: "c1", name: "Alice", + avatar: setAvatar("old"), sentAt: Date(timeIntervalSince1970: 1) + )) + await repository.apply(event( + inboxId: "alice", conversationId: "c2", name: "Alice", + avatar: setAvatar("new"), sentAt: Date(timeIntervalSince1970: 5) + )) + + let profile = await repository.profile(inboxId: "alice") + #expect(profile.displayAvatar(for: "c2")?.url == "new") + #expect(profile.displayAvatar(for: nil)?.url == "new") + #expect(profile.displayAvatar(for: "unknown")?.url == "new") + } + + @Test("purgeConversationAvatars drops only that conversation's slots") + func purgeAvatars() async throws { + let repository = try makeRepository() + await repository.warmUp() + await repository.apply(event( + inboxId: "alice", conversationId: "c1", name: "Alice", + avatar: setAvatar("a"), sentAt: Date(timeIntervalSince1970: 1) + )) + await repository.apply(event( + inboxId: "alice", conversationId: "c2", name: "Alice", + avatar: setAvatar("b"), sentAt: Date(timeIntervalSince1970: 2) + )) + + await repository.purgeConversationAvatars("c1") + + let profile = await repository.profile(inboxId: "alice") + #expect(profile.avatars["c1"] == nil) + #expect(profile.avatars["c2"]?.url == "b") + } + + @Test("fetchProfile hydrates identity and avatar from the database") + func fetchProfileHydrates() async throws { + let queue = try ProfileStoreTestSupport.makeQueue(conversations: ["c1"]) + let time = Date(timeIntervalSince1970: 1) + try await queue.write { db in + try DBProfile(inboxId: "alice", name: "Alice", profileSource: .profileUpdate, updatedAt: time).save(db) + try DBProfileAvatar( + inboxId: "alice", conversationId: "c1", url: "u", salt: salt, nonce: nonce, + encryptionKey: key, profileSource: .profileUpdate, updatedAt: time + ).save(db) + } + + let profile = try await queue.read { db in + try ProfilesRepository.fetchProfile(db, inboxId: "alice") + } + #expect(profile.name == "Alice") + #expect(profile.displayAvatar(for: "c1")?.url == "u") + + let missing = try await queue.read { db in + try ProfilesRepository.fetchProfile(db, inboxId: "nobody") + } + #expect(missing.name == nil) + } + + @Test("fetchSelfProfile reads the scoped self row, or nil when absent or another inbox") + func fetchSelfProfileReads() async throws { + let queue = try ProfileStoreTestSupport.makeQueue() + try await queue.write { db in + try DBMyProfile(inboxId: "me", name: "Me", updatedAt: Date(timeIntervalSince1970: 1)).save(db) + } + let selfProfile = try await queue.read { db in + try ProfilesRepository.fetchSelfProfile(db, inboxId: "me") + } + #expect(selfProfile?.name == "Me") + + // A leftover row for another inbox must not surface as the current user. + let other = try await queue.read { db in + try ProfilesRepository.fetchSelfProfile(db, inboxId: "someone-else") + } + #expect(other == nil) + + let emptyQueue = try ProfileStoreTestSupport.makeQueue() + let none = try await emptyQueue.read { db in + try ProfilesRepository.fetchSelfProfile(db, inboxId: "me") + } + #expect(none == nil) + } + + private func seedConversation(_ db: Database, id: String, creatorId: String = "me") throws { + try DBMember(inboxId: creatorId).save(db, onConflict: .ignore) + try DBConversation( + id: id, + clientConversationId: id, + inviteTag: "tag-\(id)", + creatorId: creatorId, + kind: .group, + consent: .allowed, + createdAt: Date(), + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: true, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil, + conversationEmoji: nil, + imageLastRenewed: nil, + isUnused: false, + hasHadVerifiedAgent: false + ).insert(db) + } + + private func localState(_ conversationId: String, publishedProfileUpdatedAt: Date?) -> ConversationLocalState { + ConversationLocalState( + conversationId: conversationId, + isPinned: false, + isUnread: false, + isUnreadUpdatedAt: Date.distantPast, + isMuted: false, + pinnedOrder: nil, + hidesInviteCard: false, + leftHostedInviteSession: false, + wasRemoved: false, + hasHadOtherMembers: false, + hasSharedInvite: false, + publishedProfileUpdatedAt: publishedProfileUpdatedAt + ) + } + + @Test("publishMyProfileToConversation enqueues a publish when never published there") + func changeAwarePublishesFirstTime() async throws { + let db: any DatabaseWriter = MockDatabaseManager.makeTestDatabase().dbWriter + let publishStore = InMemoryProfilePublishStore() + let updatedAt = Date(timeIntervalSince1970: 100) + try await db.write { db in + try self.seedConversation(db, id: "c1") + try DBMyProfile(inboxId: "me", name: "Me", updatedAt: updatedAt).save(db) + } + let repository = try makeRepository(publishStore: publishStore, database: db) + await repository.warmUp() + + try await repository.publishMyProfileToConversation("c1") + + // Enqueues a publish. The stamp is written by the publisher only after + // the ProfileUpdate is actually delivered (there is no session attached + // here), so the conversation stays unstamped and eligible for retry. + let jobs = try await publishStore.activeJobs() + #expect(!jobs.isEmpty) + let stamp = try await db.read { db in + try ConversationLocalState + .filter(ConversationLocalState.Columns.conversationId == "c1") + .fetchOne(db)?.publishedProfileUpdatedAt + } + #expect(stamp == nil) + } + + @Test("publishMyProfileToConversation skips when the profile is already current there") + func changeAwareSkipsWhenCurrent() async throws { + let db: any DatabaseWriter = MockDatabaseManager.makeTestDatabase().dbWriter + let publishStore = InMemoryProfilePublishStore() + let updatedAt = Date(timeIntervalSince1970: 100) + try await db.write { db in + try self.seedConversation(db, id: "c1") + try DBMyProfile(inboxId: "me", name: "Me", updatedAt: updatedAt).save(db) + try self.localState("c1", publishedProfileUpdatedAt: updatedAt).save(db) + } + let repository = try makeRepository(publishStore: publishStore, database: db) + await repository.warmUp() + + try await repository.publishMyProfileToConversation("c1") + + let jobs = try await publishStore.activeJobs() + #expect(jobs.isEmpty) + } + + @Test("publishMyProfileToConversation republishes when the profile changed since last publish") + func changeAwareRepublishesWhenStale() async throws { + let db: any DatabaseWriter = MockDatabaseManager.makeTestDatabase().dbWriter + let publishStore = InMemoryProfilePublishStore() + let oldAt = Date(timeIntervalSince1970: 100) + let newAt = Date(timeIntervalSince1970: 200) + try await db.write { db in + try self.seedConversation(db, id: "c1") + try DBMyProfile(inboxId: "me", name: "Me", updatedAt: newAt).save(db) + try self.localState("c1", publishedProfileUpdatedAt: oldAt).save(db) + } + let repository = try makeRepository(publishStore: publishStore, database: db) + await repository.warmUp() + + try await repository.publishMyProfileToConversation("c1") + + let jobs = try await publishStore.activeJobs() + #expect(!jobs.isEmpty) + } + + @Test("seedSelfAvatarSourceIfNeeded carries the global avatar into the publish source") + func seedsGlobalAvatarSource() async throws { + let db: any DatabaseWriter = MockDatabaseManager.makeTestDatabase().dbWriter + let publishStore = InMemoryProfilePublishStore() + try await db.write { db in + try DBMyProfile(inboxId: "me", name: "Me", imageData: Data([7, 7, 7]), updatedAt: Date(timeIntervalSince1970: 1)).save(db) + } + let repository = try makeRepository(publishStore: publishStore, database: db) + await repository.warmUp() + + await repository.seedSelfAvatarSourceIfNeeded() + + let source = try await publishStore.source(inboxId: "me") + #expect(source?.plaintext == Data([7, 7, 7])) + #expect(source?.version == 1) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/SelfProfileStoreTests.swift b/ConvosCore/Tests/ConvosCoreTests/SelfProfileStoreTests.swift new file mode 100644 index 000000000..65b720658 --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/SelfProfileStoreTests.swift @@ -0,0 +1,44 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +/// Contract tests for `SelfProfileStoreProtocol`, run against both +/// implementations. Backed by the `myProfile` table; the GRDB store resolves the +/// current inbox via its provider. +@Suite("Self profile store") +struct SelfProfileStoreTests { + @Test("GRDB implementation satisfies the contract") + func grdbContract() async throws { + let queue = try ProfileStoreTestSupport.makeQueue() + let store = GRDBSelfProfileStore( + databaseWriter: queue, + databaseReader: queue, + selfInboxIdProvider: { "me" } + ) + try await runContract(store) + } + + @Test("in-memory implementation satisfies the contract") + func inMemoryContract() async throws { + try await runContract(InMemorySelfProfileStore()) + } + + private func runContract(_ store: any SelfProfileStoreProtocol) async throws { + let t = Date(timeIntervalSince1970: 1) + let empty = try await store.load() + #expect(empty == nil) + + try await store.save(DBMyProfile(inboxId: "me", name: "Me", updatedAt: t)) + let loaded = try await store.load() + #expect(loaded?.name == "Me") + + try await store.save(DBMyProfile(inboxId: "me", name: "Me Renamed", updatedAt: t)) + let updated = try await store.load() + #expect(updated?.name == "Me Renamed") + + try await store.clear() + let cleared = try await store.load() + #expect(cleared == nil) + } +} diff --git a/ci/run-tests.sh b/ci/run-tests.sh index 030020b70..0b0268129 100755 --- a/ci/run-tests.sh +++ b/ci/run-tests.sh @@ -6,6 +6,15 @@ set -euo pipefail # --unit Run only unit tests (no backend required) # --integration Run only integration tests (requires XMTP backend) # (none) Run all tests +# +# Output design: +# - Full (XMTP-stripped) output for each step is captured to $CI_LOG_DIR and +# uploaded as a CI artifact, so nothing is lost. +# - The live console shows a reduced view (passing tests and start markers are +# dropped) wrapped in collapsible GitHub `::group::` sections. +# - On any failure, a concise summary of compile errors, failed tests, and +# crashes is printed at the very end and written to the GitHub run-summary +# panel ($GITHUB_STEP_SUMMARY). No more hunting through thousands of lines. TEST_TYPE="${1:-all}" @@ -13,43 +22,148 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" CORE_DIR="$REPO_ROOT/ConvosCore" -# Filter out noisy XMTP Rust library logs (timestamps like 2024-01-01T...) +CI_LOG_DIR="${CI_LOG_DIR:-$REPO_ROOT/ci-test-logs}" +mkdir -p "$CI_LOG_DIR" + +# Filter out noisy XMTP Rust library logs (timestamps like 2024-01-01T...). +# Applied before capture so neither the saved log nor the console carries them; +# the full XMTP logs go to CONVOS_TEST_XMTP_LOG_DIR separately. filter_xmtp_logs() { grep -v -E "^[0-9]{4}-[0-9]{2}-[0-9]{2}T" || true } -# Print a concise summary of test failures from a captured run log, so CI -# surfaces what failed without scrolling the full ~19k-line output. -print_failure_summary() { +# Reduce the live console view: drop passing Swift Testing lines (✔), the +# per-test "started" markers (◇), and any leftover timestamped logs. Failures +# (✘), `error:` lines, and structural output are kept. The saved log keeps +# everything (minus XMTP noise) for the artifact. +filter_console() { + grep -v -E \ + -e '^[0-9]{4}-[0-9]{2}-[0-9]{2}T' \ + -e '✔' \ + -e '◇' \ + -e 'Test .* started' \ + || true +} + +# Run a command, capturing full output to a log file and streaming a reduced +# view to the console inside a collapsible group. Returns the command's status +# (not tee's / grep's, which always succeed). +run_capture() { local log="$1" - local found=0 - echo "" - echo "================== FAILED TEST SUMMARY ==================" + local label="$2" + shift 2 + local rc=0 + echo "::group::$label" + "$@" 2>&1 | filter_xmtp_logs | tee "$log" | filter_console || rc=${PIPESTATUS[0]} + echo "::endgroup::" + return "$rc" +} - # Graceful Swift Testing failures: every failure line carries the ✘ glyph - # (passes use ✔) -- failed tests, failed suites, recorded issues with - # file:line + message, and the final run summary. - if grep -F '✘' "$log"; then - found=1 - fi +# Build a concise failure summary from a captured log: compile errors, failed +# tests/issues (✘), and crash markers with the tests that were in flight. Prints +# to the console (at the end, where it is easy to find) and appends a markdown +# panel to $GITHUB_STEP_SUMMARY when running in GitHub Actions. +emit_failure_summary() { + local log="$1" + local label="${2:-Tests}" - # A `fatalError`/precondition crash aborts the process before any ✘ lines - # are written, so surface the crash message and the tests that were in - # flight when it died (the crash suspects). - if grep -nE 'Fatal error|fatalError|Crashed|exited abnormally|signal SIG' "$log"; then - found=1 - echo "" - echo "--- tests started but never finished (crash suspects) ---" - comm -23 \ + local compile_errors compile_error_locations test_failures crash_lines crash_suspects="" + # Any compiler/macro/linker diagnostic, deduped. Matches `file.swift:l:c: + # error:`, `macro expansion ...: error:`, and linker `error:` lines -- not + # just the file:line:col form (a macro-expansion error reports the message + # on a line with no `.swift:` prefix, which the old grep missed). + compile_errors="$(grep -hE ': error: ' "$log" 2>/dev/null | sort -u || true)" + # Source locations tied to those errors: direct `error:` sites plus the + # `note: ... originates here` line a macro error points at (the user's real + # call site). Path trimmed to repo-relative for readability. + compile_error_locations="$(grep -hE '\.swift:[0-9]+:[0-9]+: (error:|note: .*originates here)' "$log" 2>/dev/null \ + | grep -hoE '[A-Za-z0-9_/.-]+\.swift:[0-9]+:[0-9]+' | sed -E 's#.*/convos-ios/##' | sort -u || true)" + # Swift Testing failures carry the ✘ glyph; XCTest failures use + # "Test Case '...' failed" / ": error: -[Suite test]". Catch both. + test_failures="$(grep -hE "✘|Test Case .* failed|Test Suite .* failed|: error: -\[" "$log" 2>/dev/null || true)" + crash_lines="$(grep -hE 'Fatal error|fatalError|Crashed|exited abnormally|signal SIG' "$log" 2>/dev/null || true)" + if [[ -n "$crash_lines" ]]; then + crash_suspects="$(comm -23 \ <(grep -oE 'Test "[^"]+" started' "$log" | sort -u) \ <(grep -oE 'Test "[^"]+" (passed|failed)' "$log" | sed -E 's/ (passed|failed)$/ started/' | sort -u) \ - || true + 2>/dev/null || true)" + fi + + echo "" + echo "================== FAILURE SUMMARY: $label ==================" + if [[ -n "$compile_errors" ]]; then + echo "-- compile errors --" + echo "$compile_errors" + fi + if [[ -n "$compile_error_locations" ]]; then + echo "-- at --" + echo "$compile_error_locations" + fi + if [[ -n "$test_failures" ]]; then + echo "-- failed tests / recorded issues --" + echo "$test_failures" + fi + if [[ -n "$crash_lines" ]]; then + echo "-- crash --" + echo "$crash_lines" + if [[ -n "$crash_suspects" ]]; then + echo "-- in flight when it crashed (suspects) --" + echo "$crash_suspects" + fi + fi + if [[ -z "$compile_errors$compile_error_locations$test_failures$crash_lines" ]]; then + echo "(no recognized failure markers; see full log)" + fi + echo "Full log: $log" + echo "============================================================" + + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "### ❌ ${label} failed" + if [[ -n "$compile_errors" ]]; then + echo "" + echo "**Compile errors**" + echo '```' + echo "$compile_errors" + if [[ -n "$compile_error_locations" ]]; then + echo "" + echo "at:" + echo "$compile_error_locations" + fi + echo '```' + fi + if [[ -n "$test_failures" ]]; then + echo "" + echo "**Failed tests / recorded issues**" + echo '```' + echo "$test_failures" + echo '```' + fi + if [[ -n "$crash_lines" ]]; then + echo "" + echo "**Crash**" + echo '```' + echo "$crash_lines" + if [[ -n "$crash_suspects" ]]; then + echo "" + echo "In flight when it crashed:" + echo "$crash_suspects" + fi + echo '```' + fi + echo "" + echo "_Full logs are attached as a workflow artifact._" + } >> "$GITHUB_STEP_SUMMARY" fi +} - if [[ "$found" -eq 0 ]]; then - echo "(no failure markers found; see full log above)" +emit_success_summary() { + local label="$1" + echo "" + echo "==> $label completed successfully" + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + echo "### ✅ ${label} passed" >> "$GITHUB_STEP_SUMMARY" fi - echo "========================================================" } cd "$CORE_DIR" @@ -57,76 +171,82 @@ cd "$CORE_DIR" case "$TEST_TYPE" in --unit) echo "==> Running unit tests (no backend required)" - echo "" + failed=0 - echo "==> Running ConvosAppData tests..." - swift test --package-path "$REPO_ROOT/ConvosAppData" 2>&1 | filter_xmtp_logs + APPDATA_LOG="$CI_LOG_DIR/convosappdata-tests.log" + if ! run_capture "$APPDATA_LOG" "ConvosAppData tests" \ + swift test --package-path "$REPO_ROOT/ConvosAppData"; then + emit_failure_summary "$APPDATA_LOG" "ConvosAppData tests" + failed=1 + fi + + INVITES_LOG="$CI_LOG_DIR/convosinvites-tests.log" + if ! run_capture "$INVITES_LOG" "ConvosInvites tests" \ + swift test --package-path "$REPO_ROOT/ConvosInvites"; then + emit_failure_summary "$INVITES_LOG" "ConvosInvites tests" + failed=1 + fi - echo "" - echo "==> Running ConvosInvites tests..." - swift test --package-path "$REPO_ROOT/ConvosInvites" 2>&1 | filter_xmtp_logs + if [[ "$failed" -ne 0 ]]; then + exit 1 + fi + emit_success_summary "Unit tests" ;; --integration) echo "==> Running integration tests" - echo "" - echo "Environment:" echo " XMTP_NODE_ADDRESS=${XMTP_NODE_ADDRESS:-not set}" - echo "" if [[ -z "${XMTP_NODE_ADDRESS:-}" ]]; then echo "Error: XMTP_NODE_ADDRESS environment variable is required for integration tests" exit 1 fi - # Build first to avoid parallel build issues - echo "==> Building tests..." - swift build --build-tests -q 2>&1 | filter_xmtp_logs + # Build first to avoid parallel build issues. A compile failure here is a + # common cause of "the job failed but there is no test summary" -- so + # summarize the build log before exiting. + BUILD_LOG="$CI_LOG_DIR/integration-build.log" + if ! run_capture "$BUILD_LOG" "Build tests" swift build --build-tests; then + emit_failure_summary "$BUILD_LOG" "Build" + exit 1 + fi - echo "" - echo "==> Running tests (with retry on failure)..." - # Integration tests depend on an ephemeral XMTP backend where - # operations can have highly variable latency. Retry once on - # failure to reduce flake rate. + # Integration tests depend on an ephemeral XMTP backend where operations + # can have highly variable latency. Retry once on failure to reduce flake. MAX_ATTEMPTS=2 ATTEMPT=1 - TEST_LOG="$(mktemp)" while [[ $ATTEMPT -le $MAX_ATTEMPTS ]]; do - echo "==> Attempt $ATTEMPT/$MAX_ATTEMPTS" - # `tee` mirrors output to the console and captures it so the failure - # summary below can list which tests failed. `pipefail` (set above) - # makes the `if` reflect `swift test`'s status, not `tee`'s. - if swift test --skip-build 2>&1 | filter_xmtp_logs | tee "$TEST_LOG"; then + TEST_LOG="$CI_LOG_DIR/integration-tests-attempt-$ATTEMPT.log" + if run_capture "$TEST_LOG" "Integration tests (attempt $ATTEMPT/$MAX_ATTEMPTS)" \ + swift test --skip-build; then + emit_success_summary "Integration tests" break fi if [[ $ATTEMPT -eq $MAX_ATTEMPTS ]]; then - echo "" echo "==> Tests failed after $MAX_ATTEMPTS attempts" - print_failure_summary "$TEST_LOG" + emit_failure_summary "$TEST_LOG" "Integration tests" exit 1 fi - echo "" echo "==> Attempt $ATTEMPT failed, retrying..." - echo "" ATTEMPT=$((ATTEMPT + 1)) done ;; all|"") echo "==> Running all tests" - echo "" - # Build first - echo "==> Building tests..." - swift build --build-tests -q 2>&1 | filter_xmtp_logs + BUILD_LOG="$CI_LOG_DIR/build.log" + if ! run_capture "$BUILD_LOG" "Build tests" swift build --build-tests; then + emit_failure_summary "$BUILD_LOG" "Build" + exit 1 + fi - echo "" - echo "==> Running tests..." - TEST_LOG="$(mktemp)" - if ! swift test --skip-build 2>&1 | filter_xmtp_logs | tee "$TEST_LOG"; then - print_failure_summary "$TEST_LOG" + TEST_LOG="$CI_LOG_DIR/tests.log" + if ! run_capture "$TEST_LOG" "Tests" swift test --skip-build; then + emit_failure_summary "$TEST_LOG" "Tests" exit 1 fi + emit_success_summary "Tests" ;; *) echo "Unknown option: $TEST_TYPE" @@ -136,4 +256,4 @@ case "$TEST_TYPE" in esac echo "" -echo "==> Tests completed successfully" +echo "==> Done" diff --git a/docs/plans/2026-06-29-profile-table-implementation.md b/docs/plans/2026-06-29-profile-table-implementation.md new file mode 100644 index 000000000..f95abac50 --- /dev/null +++ b/docs/plans/2026-06-29-profile-table-implementation.md @@ -0,0 +1,1031 @@ +# Technical design: Profile-table identity refactor (iOS) + +- Status: draft for review (architect design) +- Date: 2026-06-29 +- Spec: `docs/specs/profile-contact-identity-model.md` (2026-06-29 update is the + decided direction) +- Deferred follow-up: `docs/adr/014-cross-conversation-profile-image-caching.md` + (digest cache - out of scope here; reserve columns only) + +This is the detailed technical design for "populate all views from `Profile`, +instead of `MemberProfile`." It turns the spec into concrete iOS types, file +paths, signatures, and a stacked-PR sequence. It mirrors the shipped Android +design while respecting iOS constraints. + +## 1. Goals and non-goals + +In scope: + +- A canonical `DBProfile` store keyed by `inboxId`, owned by a new + `ProfilesRepository`, as the single source of identity (name + avatar) for all + views. +- Per-conversation avatar slots in `DBProfileAvatar` (encryption is per group). +- Self identity in `DBSelfProfile` plus a durable publish queue + (`DBProfilePublishJob` + `DBProfileAvatarSource`) replacing the direct + fan-out in `MyProfileWriter` / `ProfileSyncCoordinator`. +- A precedence + recency merge engine for inbound profile events. +- ViewModel-layer identity resolution replacing + `@Environment(\.memberContactOverride)` and `Profile.overlaying(contact:)`. +- One-time `ProfileBackfill` and a `ConversationProfileCleaner`. +- Migrations, including a gated column-drop after backfill ships. + +Non-goals (explicitly deferred): + +- Cross-conversation avatar download dedup via advertised content digest + (ADR 014). We only reserve two nullable columns so the follow-up needs no + second migration. +- `localNickname` user-assigned override. +- `DBAgentTemplate` <-> agent profile sync (separate effort). + +## 2. Constraints (from CLAUDE.md, must hold throughout) + +- ConvosCore must compile on macOS: no `UIKit`, no `#if canImport(UIKit)`, use + `ImageType`. Anything iOS-specific goes through a protocol implemented in + ConvosCoreiOS or injected from the app. +- No force-unwraps / implicitly-unwrapped optionals; `guard let` early returns. +- SwiftLint: `enum Constant` at bottom of scope, sorted imports, max function + body 125 lines, max type body 625 lines, max 6 params, no all-caps in + comments, ASCII punctuation only. +- Type-check budget 100ms: hoist conditionals to typed `let`s in any new SwiftUI + bodies; keep view bodies small. +- Use the `XMTPClientProvider` / `MessageSender` abstraction, never `XMTPiOS` + types directly in business logic. +- Run `swift test --package-path ConvosCore` against the local XMTP Docker node + before every push. + +## 3. Architecture overview + +``` +View / ViewModel (Convos app target) + reads identity via injected ProfilesRepositoryProtocol + | + v +ProfilesRepository (ConvosCore) <-- warmed in-memory cache + merge engine + | | | + v v v +IProfileStore ISelfProfileStore IProfilePublishStore (protocols) + | | | + GRDB impls (ConvosCore) + in-memory impls (tests) + | + v +GRDB DatabaseWriter/Reader (SharedDatabaseMigrator schema) + +Inbound: StreamProcessor.processProfile* -> ProfilesRepository.apply(event) +Outbound: edit self -> ProfilesRepository.publishMyProfile -> ProfilePublisher + (durable queue) -> encrypt+upload+send via MessageSender +Lifecycle: SessionManager.init -> backfill -> warmUp; ConversationProfileCleaner +``` + +Everything new lives in ConvosCore except the ViewModel rewiring (app target) and +any image-key/upload bridges that already exist on the app/API side. + +New directory: `ConvosCore/Sources/ConvosCore/Profiles/Repository/` for the +repository, stores, merge engine, publisher, backfill, and cleaner. DB models go +under the existing `Storage/Database Models/`. Migrations stay in +`SharedDatabaseMigrator.swift`. + +## 4. Data layer + +### 4.1 DB models (GRDB) - `Storage/Database Models/` + +`DBProfile` (per-person identity, PK `inboxId`): + +```swift +struct DBProfile: Codable, FetchableRecord, PersistableRecord, Hashable, Sendable { + let inboxId: String + var name: String? + var memberKind: DBMemberKind? // reuse existing enum + var metadata: ProfileMetadata? // existing JSON codable type + var profileSource: ProfileSource // see merge engine; persisted as text + var avatarContentDigest: String? // reserved for ADR 014, always nil now + var updatedAt: Date + enum Columns { /* inboxId, name, memberKind, metadata, profileSource, avatarContentDigest, updatedAt */ } +} +``` + +`DBProfileAvatar` (per-person, per-conversation slot, PK `(inboxId, conversationId)`): + +```swift +struct DBProfileAvatar: Codable, FetchableRecord, PersistableRecord, Hashable, Sendable { + let inboxId: String + let conversationId: String + var url: String? + var salt: Data? + var nonce: Data? + var key: Data? + var profileSource: ProfileSource + var contentDigest: String? // reserved for ADR 014, always nil now + var updatedAt: Date +} +``` + +`DBSelfProfile` (current user canonical identity, PK `inboxId`): + +```swift +struct DBSelfProfile: Codable, FetchableRecord, PersistableRecord, Hashable, Sendable { + let inboxId: String + var name: String? + var metadata: ProfileMetadata? + var updatedAt: Date +} +``` + +`DBProfileAvatarSource` (current user plaintext source image, PK `inboxId`): + +```swift +struct DBProfileAvatarSource: Codable, FetchableRecord, PersistableRecord, Sendable { + let inboxId: String + var plaintext: Data + var version: Int64 // bumped each time the user sets a new avatar + var updatedAt: Date +} +``` + +`DBProfilePublishJob` (durable publish queue, PK `id`): + +```swift +struct DBProfilePublishJob: Codable, FetchableRecord, PersistableRecord, Sendable { + let id: String + var seq: Int64 // monotonic enqueue order + var conversationId: String + var sourceVersion: Int64? // pins DBProfileAvatarSource.version; nil = name-only + var hasAvatar: Bool + var state: ProfilePublishJobState // pending | uploading | done + var ciphertext: Data? // cached after first encrypt + var salt: Data? + var nonce: Data? + var groupKey: Data? + var filename: String? + var uploadedURL: String? + var attemptCount: Int64 + var nextAttemptAt: Date + var lastError: String? + var createdAt: Date + var updatedAt: Date +} + +enum ProfilePublishJobState: String, Codable, Sendable { case pending, uploading, done } +``` + +`ProfileSource` (merge precedence, persisted as text): + +```swift +enum ProfileSource: String, Codable, Sendable, Comparable { + case contact // 0 lowest (backfill) + case appData // 1 + case profileSnapshot // 2 + case profileUpdate // 3 highest + // Comparable by the ordinal above +} +``` + +### 4.2 Migrations - `SharedDatabaseMigrator.swift` + +Registered after the current latest migration, following the existing +`registerMigration(_:)` pattern. Keep each closure cheap. + +1. `"createProfileTables"`: create `profile`, `profileAvatar`, `selfProfile`, + `profileAvatarSource`, `profilePublishJob`. `profileAvatar.conversationId` + gets a foreign key to `conversation(id)` with `ON DELETE CASCADE` (defense in + depth alongside the cleaner). Indexes: `profilePublishJob(state, nextAttemptAt, seq)` + and `profilePublishJob(conversationId)`. +2. No SQL backfill migration. `ProfileBackfill` runs as startup code (section 7) + so it is testable and cheap, matching Android. +3. `"dropMemberProfileIdentityColumns"` (separate, shipped a release later): + drop identity/avatar columns from `contact` and `memberProfile`. Gated so a + partial upgrade never runs before the backfill has executed (guard on a + stored "backfill completed" marker; skip the drop if absent). + +Reserve `avatarContentDigest` / `contentDigest` columns in step 1 per spec +"Anticipating ADR 014"; they are created nullable and never written by this work. + +## 5. Store layer - `Profiles/Repository/Stores/` + +Three protocols, each with a GRDB implementation (ConvosCore) and an in-memory +implementation (tests). Stores are thin: persistence + change signals only; all +merge logic lives in the repository. + +`IProfileStore`: + +```swift +protocol IProfileStore: Sendable { + // identity + func saveIdentity(_ profile: DBProfile) async throws + func identity(inboxId: String) async throws -> DBProfile? + func identities(inboxIds: [String]) async throws -> [DBProfile] + func allIdentities() async throws -> [DBProfile] + // avatars + func saveAvatar(_ avatar: DBProfileAvatar) async throws + func avatar(inboxId: String, conversationId: String) async throws -> DBProfileAvatar? + func avatars(inboxId: String) async throws -> [DBProfileAvatar] + func avatars(inboxIds: [String]) async throws -> [DBProfileAvatar] + func allAvatars() async throws -> [DBProfileAvatar] + func deleteAvatars(conversationId: String) async throws + // lifecycle + func delete(inboxId: String) async throws + func deleteAll() async throws + // signal + var events: AsyncStream { get } +} + +enum ProfileStoreEvent: Sendable { + case identityChanged(inboxId: String) + case avatarChanged(inboxId: String, conversationId: String) + case removed(inboxId: String) +} +``` + +`ISelfProfileStore`: + +```swift +protocol ISelfProfileStore: Sendable { + func save(_ profile: DBSelfProfile) async throws + func load() async throws -> DBSelfProfile? + func clear() async throws + var updates: AsyncStream { get } +} +``` + +`IProfilePublishStore`: + +```swift +protocol IProfilePublishStore: Sendable { + // source image + func setSource(_ source: DBProfileAvatarSource) async throws + func source(inboxId: String) async throws -> DBProfileAvatarSource? + func clearSource(inboxId: String) async throws + // job queue + func enqueue(_ job: DBProfilePublishJob) async throws + func update(_ job: DBProfilePublishJob) async throws + func job(id: String) async throws -> DBProfilePublishJob? + func claimNextReady(now: Date) async throws -> DBProfilePublishJob? + func activeJobs() async throws -> [DBProfilePublishJob] + func jobs(conversationId: String) async throws -> [DBProfilePublishJob] + func nextSeq() async throws -> Int64 + func earliestNextAttempt() async throws -> Date? + func deleteJob(id: String) async throws + func deleteJobs(conversationId: String) async throws + func supersedeOlderThan(conversationId: String, seq: Int64) async throws + func deleteAll() async throws + var events: AsyncStream { get } +} + +enum ProfilePublishEvent: Sendable { + case enqueued(id: String, conversationId: String) + case updated(id: String) +} +``` + +GRDB impls: `GRDBProfileStore`, `GRDBSelfProfileStore`, `GRDBProfilePublishStore`, +each taking `databaseWriter`/`databaseReader`, emitting events via a continuation +on write. In-memory impls: dictionaries guarded by an `actor`. `claimNextReady` +filters `state != done && nextAttemptAt <= now`, returns the minimum by `seq`. + +## 6. ProfilesRepository - `Profiles/Repository/ProfilesRepository.swift` + +Protocol (app and other ConvosCore code depend only on this): + +```swift +public protocol ProfilesRepositoryProtocol: Sendable { + // lifecycle + func warmUp() async + func stop() async + // read - others + func profilesPublisher(inboxIds: [String]) -> AnyPublisher<[String: Profile], Never> + func profiles(inboxIds: [String]) async -> [String: Profile] + func profilePublisher(inboxId: String) -> AnyPublisher + func profile(inboxId: String) async -> Profile? + func sortedFilteredProfileInboxIds(_ inboxIds: [String], query: String) async -> [String] + // read - self + func selfProfilePublisher() -> AnyPublisher + func selfProfile() async -> Profile? + func selfAvatarSourceBytes() async -> Data? + // write - self + func updateSelfProfile(_ edit: SelfProfileEdit) async throws + func publishMyProfile(displayName: String?, avatarBytes: Data?, priorityConversationId: String?) async throws + func publishMyProfileToConversation(_ conversationId: String) async throws + // inbound seam + func apply(_ event: ProfileDomainEvent) async + var profileChanges: AnyPublisher { get } // inboxId on any change + // cleanup + func purgeConversationAvatars(_ conversationId: String) async + // session binding (publisher needs XMTP image keys + send) + func bind(session: ProfilePublishSession?) async +} +``` + +Key internals: + +- Warmed in-memory cache: `[String: Profile]` plus `[String: [conversationId: Avatar]]`, + populated in `warmUp()` from `allIdentities()` + `allAvatars()` + `selfProfile`. + Reads serve from cache; writes persist to the store first, then update the + cache (and emit publishers) only after the store write succeeds, so a failed + write leaves the cache consistent with the database. Cache mutations are + serialized on an internal `actor`. +- `profilePublisher` / `profilesPublisher` back onto a Combine subject fed by + store events and cache mutations; `profileChanges` emits the changed `inboxId` + for downstream repos/read-models to invalidate. +- The Combine surface keeps the app's existing `@Observable` ViewModels simple; + if a ViewModel prefers async, the snapshot `profiles(inboxIds:)` is available. + +`Profile` model change: today `Profile` is conversation-scoped +(`id = inboxId@conversationId`, carries one avatar). New shape is per-person with +per-conversation avatars and an explicit display resolver: + +```swift +public struct Profile: Identifiable, Hashable, Sendable { + public var id: String { inboxId } + public let inboxId: String + public let name: String? + public let memberKind: DBMemberKind? + public let metadata: ProfileMetadata? + public let avatars: [String: Avatar] // keyed by conversationId + public let updatedAt: Date + public func displayAvatar(for conversationId: String?) -> Avatar? // slot, else newest + public static func empty(inboxId: String) -> Profile +} +``` + +`Avatar` value type (spec Revision 6): + +```swift +public enum Avatar: Hashable, Sendable { + case plain(url: String, updatedAt: Date) + case encrypted(url: String, salt: Data, nonce: Data, key: Data, updatedAt: Date) + public static func from(url: String?, salt: Data?, nonce: Data?, key: Data?, updatedAt: Date) -> Avatar? +} +``` + +`ImageCacheable` conformance moves onto `Avatar` (with the conversation-scoped +identity key preserved), so `AvatarView` takes an `Avatar` or a `(Profile, +conversationId)` pair rather than the old per-conversation `Profile`. + +### 6.1 Merge engine - `Profiles/Repository/ProfileMerge.swift` + +Pure functions, no I/O, fully unit-testable: + +```swift +enum ProfileMerge { + static func mergeIdentity(existing: DBProfile?, incoming: IncomingIdentity, source: ProfileSource, sentAt: Date) -> DBProfile + static func mergeAvatar(existing: DBProfileAvatar?, incoming: IncomingAvatar, source: ProfileSource, sentAt: Date) -> DBProfileAvatar? + // helpers + static func fillBlank(_ existing: String?, _ incoming: String?) -> String? + static func preserveVerifiedKind(_ existing: DBMemberKind?, _ incoming: DBMemberKind?) -> DBMemberKind? +} +``` + +Rules (spec Revision 3 + existing guards): + +- Higher `ProfileSource` always wins; equal source -> newer `sentAt` wins; lower + source only fills blanks (`fillBlank`). +- Name: empty/absent never clears a populated name. +- Avatar tri-state: only `set` or `explicit-clear` changes the slot; `silent` + leaves it. +- `preserveVerifiedKind`: never downgrade `agent:convos` / `agent:user-oauth` to + generic `agent`. + +`apply(_ event:)` decodes the event to `IncomingIdentity` / `IncomingAvatar`, +runs the merge, persists via the stores, updates the cache, and emits +`profileChanges`. Self-echo guard: events whose sender is the current inbox are +ignored (self identity is authored locally). + +### 6.2 ProfileDomainEvent - `Profiles/Repository/ProfileDomainEvent.swift` + +```swift +public enum ProfileDomainEvent: Sendable { + case profileUpdated(inboxId: String, conversationId: String, identity: IncomingIdentity, avatar: IncomingAvatar?, sentAt: Date) + case profileSnapshotReceived(inboxId: String, conversationId: String, identity: IncomingIdentity, avatar: IncomingAvatar?, sentAt: Date) + case appDataProfilesObserved(/* ... */) +} +``` + +`IncomingAvatar` distinguishes `.set(url,salt,nonce,key)`, `.explicitClear`, and +`.silent` so the tri-state survives into the merge. + +## 7. Self profile + durable publisher + +### 7.1 SelfProfileEdit DSL + +```swift +public struct SelfProfileEdit: Sendable { + public enum Field: Sendable { case keep; case set(T) } + public var name: Field = .keep + public var metadata: Field = .keep +} +``` + +`updateSelfProfile` reads the current `DBMyProfile`, applies the set fields, and +persists locally, bumping `updatedAt`. It does not fan out. Propagation is lazy +and change-aware: an edit reaches a conversation only when the user next opens or +sends in it (`publishMyProfileToConversation` compares the conversation's +`ConversationLocalState.publishedProfileUpdatedAt` against `DBMyProfile.updatedAt` +and publishes only when stale). A name-only publish re-sends the existing avatar +rather than clearing it, so changing the display name never blanks the avatar. + +### 7.2 ProfilePublisher - `Profiles/Repository/ProfilePublisher.swift` + +An `actor` owned by `ProfilesRepository`, backed by `IProfilePublishStore`. +Replaces the direct `MyProfileWriter.update*` / `syncFromGlobalProfile` / +`ProfileSyncCoordinator` fan-out. Under the lazy propagation model there is no +publish-to-all-conversations entry point: `updateAvatarSource` only records the +new source bytes, and `publishConversation` delivers the current profile to one +conversation (name-only jobs re-send the existing avatar). The conversation's +`publishedProfileUpdatedAt` is stamped only after a send is confirmed. + +```swift +actor ProfilePublisher { + func attach(session: ProfilePublishSession) async + func detach() + func updateAvatarSource(_ avatarBytes: Data) async throws + func publishConversation(_ conversationId: String) async throws +} +``` + +`ProfilePublishSession` is the injected seam to XMTP + upload, keeping the +publisher free of `XMTPiOS`: + +```swift +public protocol ProfilePublishSession: Sendable { + var inboxId: String { get } + func allConversationIds() async throws -> [String] + func ensureImageKey(conversationId: String) async throws -> Data? // nil -> drop job + func uploadEncryptedAvatar(_ ciphertext: Data, filename: String) async throws -> String + func sendProfileUpdate(_ update: ProfileUpdate, conversationId: String) async throws +} +``` + +The app/MessagingService provides the concrete implementation, reusing today's +`apiClient.uploadAttachment(...)`, `ImageEncryption.encrypt(...)`, +`ProfileUpdateCodec`, and `messageSender(for:)` -> `send(encodedContent:)`. + +Drain loop: + +1. `publish` records the source image (`setSource`, bump `version`), then + enqueues one `DBProfilePublishJob` per conversation, priority conversation + first; `supersedeOlderThan` drops superseded intents. +2. Single loop claims `claimNextReady`, encrypts once (caches ciphertext + crypto + on the job so restarts re-upload identical bytes), uploads, sends, writes the + local `DBProfileAvatar` slot, marks job `done`, emits a member-profile-updated + signal. +3. Failure reschedules only that job (capped exponential backoff + jitter); the + loop moves on (no head-of-line blocking). `sourceVersion` pins the source; + stale-version jobs drop without uploading. +4. Republish a conversation when its group image key rotates (observe the + conversation store), only for conversations already carrying an encrypted + avatar. + +This subsumes `ProfileSyncCoordinator` (delete it once callers move over). + +## 8. Backfill + cleaner + +`ProfileBackfill` - `Profiles/Repository/ProfileBackfill.swift`: + +```swift +struct ProfileBackfill { + func run() async throws // idempotent, safe to re-run +} +``` + +Reads all `DBMemberProfile` rows; for non-self inboxes writes identity into +`DBProfile` and avatars into `DBProfileAvatar` via the merge at +`ProfileSource.contact` with a floor timestamp (epoch 0) so any real later event +supersedes; seeds `DBSelfProfile` from the current user's own rows if empty; +writes a persisted "backfill completed" marker (gates the later column-drop +migration). Runs once at startup before `warmUp()`. + +`ConversationProfileCleaner` - `Profiles/Repository/ConversationProfileCleaner.swift`: +an object observing conversation deletions (hook the existing delete path in +`SessionStateMachine` / conversation deletion) and calling +`purgeConversationAvatars(conversationId)`. The FK cascade is defense in depth; +the cleaner matches Android and covers non-cascade stores. Identity in +`DBProfile` is preserved. + +## 9. Inbound seam - `Syncing/StreamProcessor.swift` + +`processProfileUpdate` and `processProfileSnapshot` keep decoding and the +existing agent-verification logic, but the write target changes: + +- Replace the `ContactsWriter.saveMemberProfileAndMirrorToContactInTransaction` + call with construction of a `ProfileDomainEvent` and a call to + `profilesRepository.apply(event)`. +- During transition, `DBMemberProfile` retains only membership/role/`memberKind` + needed by non-identity consumers; the identity/avatar fields it still has are + no longer read for rendering (and are dropped in the gated migration). +- Inject `profilesRepository` into `StreamProcessor` via its initializer (it is + already constructed inside `MessagingService`). + +Snapshot precedence (skip-if-present today) becomes the merge's +`profileSnapshot` source level, removing the ad hoc skip. + +## 10. ViewModel rewiring (app target) + +Retire `@Environment(\.memberContactOverride)` (`Convos/Contacts/MemberNameOverride.swift`) +and `Profile.overlaying(contact:)` (`Convos/Contacts/Profile+ContactOverlay.swift`). +Replace with `ProfilesRepositoryProtocol` injected into each ViewModel, plus a +small set of pure display helpers: + +`ConversationDisplay` (app target, pure functions over `[String: Profile]`): + +```swift +enum ConversationDisplay { + static func name(inboxId: String, _ profiles: [String: Profile]) -> String + static func formattedNames(_ members: [ConversationMember], _ profiles: [String: Profile]) -> String + static func sortedByRole(_ members: [ConversationMember], _ profiles: [String: Profile]) -> [ConversationMember] + static func conversationDisplayName(_ conversation: Conversation, _ profiles: [String: Profile]) -> String +} +``` + +Surfaces to rewire (each obtains profiles via the repository for the inboxIds it +renders, with `Profile.empty` fallback preserving "Somebody"/"Agent"): + +- `Convos/Conversations List/ConversationsViewModel.swift` and its + `ConversationsViewController` / `ConversationListItemCell` (was + `memberContactOverride`). +- `Convos/Conversation Detail/ConversationViewModel.swift` and the message list + (`MessagesViewRepresentable` / `MessagesViewController` / + `MessagesListItemTypeCell`). +- `Convos/Contacts/ContactsViewModel.swift` (contact list -> repository + `sortedFilteredProfileInboxIds` + per-row profile lookup). +- Contact card / member detail VMs. +- Read-by / read-receipts VM. +- `AvatarView` / `ProfileAvatarView` / `ConversationAvatarView` / + `MessageAvatarView`: take an `Avatar` (resolved via `Profile.displayAvatar(for:)`). +- `Convos/Profile/ProfileSettingsViewModel.swift` and `MyProfileViewModel.swift`: + read/write self via `selfProfilePublisher` / `publishMyProfile`. + +Watch the type-check budget in any touched SwiftUI body (hoist conditionals). + +### 10.1 Interim avatar-freshness fix to remove during cutover + +Before this refactor lands, system-message and read-receipt rows render a +participant's avatar from a stale source after a profile-image change: the rows +resolve through a per-message `Profile` snapshot (whose avatar URL goes stale) +overlaid with the lagging `DBContact`, while the message bubble already renders +the current per-conversation member profile. A stopgap was added to bring those +rows to parity with the bubble, and it becomes dead code once identity resolves +from `ProfilesRepository`; delete it in PR9: + +- Member-backed contact override: `Contact.liveOverride(member:stored:)` in + `Convos/Contacts/Profile+ContactOverlay.swift`, consumed by + `ConversationView.contactOverride`. It prefers the current conversation + member's avatar (the same source the bubble uses) over the lagging stored + contact. `displayAvatar(for:)` already returns the conversation's avatar slot + from the canonical store, so this helper and the whole `memberContactOverride` + / `overlaying(contact:)` path go away together. This also covers the current + user, since self is a conversation member. + +This is a correctness stopgap, not new architecture: it feeds the existing render +path the freshest data available today rather than changing how identity is +stored, so it retires cleanly when the repository becomes the source of truth. + +## 11. Composition root + lifecycle + +`ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift` and +`Messaging/MessagingService.swift`: + +- Add factory methods on `MessagingService`: `profileStore()`, + `selfProfileStore()`, `profilePublishStore()`, and `profilesRepository()` + (constructed once, cached like other services), wiring `databaseWriter` / + `databaseReader`. +- Provide a concrete `ProfilePublishSession` from `MessagingService` (it owns the + XMTP client provider, apiClient, codecs) and `bind` it to the repository. +- In `SessionManager.init`'s initialization task, after + `prewarmUnusedConversation()` and before first UI read: run + `ProfileBackfill().run()` (once), then `profilesRepository().warmUp()`, then + start `ConversationProfileCleaner`. +- On teardown: `ConversationProfileCleaner` stop, `profilesRepository().stop()` + (unbinds publisher, cancels observers). +- Inject `profilesRepository` into `StreamProcessor` at its construction site. + +## 12. Stacked PR plan (Graphite) + +Each checkpoint compiles and has its package tests passing before stacking the +next. PR 1 is this design doc. + +### As shipped (actual Graphite stack) + +The plan below was consolidated into fewer, larger PRs during implementation. +Actual stack: + +- **#1126 `unified profile - new database tables/persistence`** [merged into stack] + = foundation: DB models, `createProfileTables` migration (with FK + `ON DELETE CASCADE` on `profileAvatar`/`profilePublishJob`), `ProfileSource`, + `Avatar`, and the three stores. Dormant. +- **#1128 `unified profile - merge, repository, and unified profile`** = engine: + `ProfileMerge` / `ProfileDomainEvent` / `SelfProfileEdit`, `ProfilesRepository` + + `UnifiedProfile` (transitional name; replaces the old `Profile` at cutover), + `ProfileBackfill`, `ProfilePublisher` + `ProfilePublishSession`. Dormant. +- **#1129 `unified profile - activate backfill and populate live data`** = + activate: `ProfileMemberMirror` (observes `memberProfile`, re-runs the + idempotent backfill), `MessagingProfilePublishSession` (concrete session), + and the `MessagingService`/`SessionManager` wiring (backfill -> warmUp -> + mirror, self-guarded on inbox-ready). First PR that runs at startup; still + dormant (nothing renders from the new tables). + +Key deviations from the sketch below, all deliberate: + +- **Inbound = mirror, not the direct `apply()` seam.** Threading the repo into + `StreamProcessor` (two construction sites, async self-inbox) was too risky + blind, so during transition a `ValueObservation` mirror populates the new + tables from `memberProfile`. The clean direct seam is deferred to the cleanup + PR, where `memberProfile` is being removed anyway. +- **No standalone `ConversationProfileCleaner`.** The migration's FK + `ON DELETE CASCADE` already purges a deleted conversation's avatar slots / + publish jobs; repo-cache coherence on delete is handled at the cutover via + store observation. Resolves the open question as "cascade alone." +- **Publisher binding deferred.** The concrete session is written, but + constructing/binding `ProfilePublisher` + `repository.publishMyProfile` (and + its async self-inbox provider rework) lands with the read-surface PR, since + it's dormant until self-edits route through it at cutover. + +### Remaining PRs (consolidated 4-PR tail) + +1. **`unified profile - shadow-compare instrumentation`** [done] - + `ProfileShadowComparator`: logs where new `DBProfile` identity disagrees with + the legacy `contact` table, per inbox. Read-only; lands early so it bakes on + real data before the flip. +2. **`unified profile - repository read + write (live)`** [done] - + reactive read publishers, keep the cache live via `ValueObservation` on the + profile stores (subsumes the cleaner), fix self-profile staleness (mirror + upserts self, not seed-once), and bind the publisher + `publishMyProfile`. + Dormant, unit-tested. Prerequisite for the cutover. + - Slice 1 [done]: reactive read publishers (`profilePublisher`, + `profilesPublisher`, `selfProfilePublisher`) + static `fetchProfile` / + `fetchSelfProfile` hydration, ValueObservation-backed (bypass the cache). + - Slice 2 [done]: write side. `ProfilePublisher` self-inbox provider rework + (String -> async provider, resolved after `draining=true` to avoid + reentrancy), `ProfilesRepository` constructs/owns the publisher and exposes + `bind`/`unbind`/`publishMyProfile`/`publishMyProfileToConversation`. + `MessagingService` adds a `GRDBProfilePublishStore` and binds a + `MessagingProfilePublishSession` in `startProfileMirroring`. + - Slice 3 [done]: self-profile freshness - `ProfileBackfill.mirror` upserts + the self row instead of seeding once, guarded by a content diff, so a legacy + rename reflects on re-run while a blank legacy name never erases an existing + value. +3. **`unified profile - cutover (hard, no flag)`** [risk gate, next] - single + all-at-once PR matching Android's shape (no feature flag, no mirror). With + near-zero users we accept Android's stated backfill-must-complete risk and + fold the destructive drop in. + + Decision (2026-06-30): deduced from `convos-client` that Android shipped a + hard cutover - `ProfileBackfill.run()` runs unconditionally at `Core.start()` + before `warmUp`, `StreamProcessor` writes only the new tables via + `profilesRepository.apply(...)`, backfill `deleteAll()`s `member_profile`, and + there is no feature flag. Android deferred only the physical table drop (still + present at their v30). We go further and fold the drop in, because no user has + run an older backfill, so there is no upgrader-rollback path to preserve. + + Scope (by slice): + - [done] Direct inbound seam: all three inbound paths (`StreamProcessor`, + the NSE extension, `ConversationWriter` history) write the canonical stores + via a shared, in-transaction `ProfileInboundApplier` instead of + `DBMemberProfile`. Reuses `ProfileMerge`; preserves agent attestation via a + transient probe; prior-verified is now per-inbox; history now carries each + message's `sentAt`; inbound no longer mirrors to `contact`. Unit-tested + (attestation left to Docker integration). + - [done] Delete `ProfileMemberMirror` + `ProfileShadowComparator` and their + wiring; `startProfileMirroring`/`stopProfileMirroring` renamed to + `startProfileServices`/`stopProfileServices` (backfill + warmUp + publisher + bind only - no ongoing mirror). + - [done] Flip message/member rendering: rerouted the choke point + `DBConversationMemberProfileWithRole` (+ `DBConversation`/`DBMessage` + association chains, `fetchMemberProfiles`, `MemberProfileCache`, + `LightweightCreatorDetails`) to read `profile` + `profileAvatar` via new + `DBConversationMember.profile`/`avatarSlot`/`inviterProfileIdentity` + associations. Added `Profile.from(profile:avatar:inboxId:conversationId:)`. + Joins are now `.including(optional:)` so members without a profile row still + render. Fixed `IncomingMessageWriter.bootstrapSenderProfile` (trusted-sender + agent gate) to read the canonical `profile` table. `memberContactOverride` + stays on `contact` (it's the contacts-feature name override, orthogonal). + Behavior deltas: departed/historical member fallback dropped (renders + `.empty` placeholder); creator uses real role not forced `.superAdmin`; + `imageSourceContentDigest` is nil (minor avatar-cache-continuity loss). + - [done] Rerouted the remaining `DBMemberProfile` readers that hit an empty + table: NSE notification names (`MessagingService+PushNotifications` + `isMemberAgent` / group-title builder / `notificationMemberDisplayName`), + outgoing `ProfileSnapshotBuilder` (new `DBProfile.snapshotMemberProfile`), + `AgentVerificationWriter` (per-inbox now; marks `hasHadVerifiedAgent` across + all the agent's conversations), and the `StreamProcessor` display-name + fallback. `EncryptedImagePrefetcher` needed no change - its input is + transient `DBMemberProfile`s built from XMTP group metadata, not the DB + table. Also fixed `IncomingMessageWriter.bootstrapSenderProfile`. + - [done] `cv/unified-profile-cutover` ends here. Everyone's identity now + reads and writes through the canonical tables. `member_profile` lingers only + as vestigial local bookkeeping for the current user's own edits (nothing + reads it), so self-editing keeps working and self identity still propagates + to peers via `MyProfileWriter`'s ProfileUpdate sends. + +### Follow-up PR (cv/unified-profile-global-self-and-drop): global-only self profile [done] + +Product direction (2026-07-01): dropped per-conversation self editing. The +current user now has a single global profile, identical across all conversations. +What shipped: + +- [done] Durable publisher carries self `metadata` in the outgoing + `ProfileUpdate` (`ProfilePublishSession.sendProfileUpdate`, `ProfilePublisher`, + `MessagingProfilePublishSession`); repository gained `publishMyProfileMetadata`. +- [done] `ConversationStateManager` takes an injected `profileConversationSeeder` + (from `MessagingService`); `scheduleProfileSync` now calls + `publishMyProfileToConversation`. Removed the `ProfileSyncCoordinator` usage. +- [done] Global save sites (`ProfileSettingsViewModel.saveAndAwait`, the + onboarding/pairing path in `ConversationsViewModel`) call `publishMyProfile`. + `ProfileMetadataWriter` routes through `publishMyProfileMetadata`. +- [done] Per-conversation self editing rerouted to the global publisher: + `MyProfileViewModel` now takes a `MessagingServiceProtocol` and its edits call + `publishMyProfile` / `publishMyProfileMetadata`. `MyProfileRepository`'s + self-read now reads `DBMyProfile` only (ignores `member_profile`). +- [done] Deleted `MyProfileWriter` (+ protocol, mock), `ProfileSyncCoordinator`; + removed `myProfileWriter()` from the messaging/state-manager protocols + DI. + `ProfilesRepository` + its publish methods are now `public`. Kept + `MyGlobalProfileWriter` / `DBMyProfile` as the global edit-side model. +- [done] `member_profile` is left fully populated. The `deleteAll`-after-backfill + clear was removed after PR review: several live subsystems still read + `DBMemberProfile` (see the follow-up items in section 17), so clearing (or dropping) the + table is not yet safe. `member_profile` is simply no longer the *rendering* + identity source; the un-migrated subsystems keep working off it unchanged. +- [done] Self-source consistency (PR review): `ProfileBackfill` now also seeds + `DBSelfProfile` from the legacy global `DBMyProfile` (fill-only), and + `MyProfileRepository` reads self identity from `DBSelfProfile` (the table every + self-write path updates) so the editor's own view stays current. This is a + stopgap - `DBMyProfile` and `DBSelfProfile` still both exist and overlap; see + the follow-up items in section 17 for the consolidation. + +### Cleanup checklist for a subsequent release + +Consolidated into section 17 (Follow-up items) at the end of this document, +together with the follow-ups surfaced during review. + +--- + +### Original per-slice plan (historical reference) + +1. `profile-table-plan` - this document. [done] +2. `profile-table-models-migrations` - DB models (`DBProfile`, `DBProfileAvatar`, + `DBSelfProfile`, `DBProfileAvatarSource`, `DBProfilePublishJob`) + + `createProfileTables` migration + `ProfileSource` + `Avatar` + unit tests for + models and migration. Additive; nothing reads it yet. [done] The new `Profile` + struct shape was deferred to PR 5 (it name-collides with the existing + conversation-scoped `Profile`, which is still in use until the cutover); the + avatar key column is named `encryptionKey` (not `key`) to avoid the SQL + reserved word and match Android. +3. `profile-table-stores` - three store protocols + GRDB impls + in-memory impls + + store unit tests (round-trip, events, `claimNextReady` ordering). +4. `profile-table-merge` - `ProfileMerge` + `ProfileDomainEvent` + + `SelfProfileEdit` + exhaustive merge unit tests (precedence, recency, + blank-fill, tri-state avatar, verified-kind preservation). +5. `profile-table-repository` - `ProfilesRepository` (cache, read/write, + `apply`, publishers) + `ProfileBackfill` + repository tests using in-memory + stores. Still not wired into UI or sync. +6. `profile-table-publisher` - `DBProfilePublishJob` flow already exists from + PR2/3; add `ProfilePublisher` + `ProfilePublishSession` protocol + the + MessagingService-side concrete session + durable-queue tests (restart, + backoff, supersede, stale version). +7. `profile-table-inbound-seam` - rewire `StreamProcessor.processProfile*` to + `apply(event)`; integration tests (out-of-order, snapshot-vs-update + precedence) against the Docker node. +8. `profile-table-lifecycle` - SessionManager/MessagingService wiring, backfill + + warmUp ordering, `ConversationProfileCleaner`, publisher bind/unbind. + Behind everything reading the new repository. +9. `profile-table-viewmodels` - flip the ViewModels/Views to the repository + + `ConversationDisplay`; delete `memberContactOverride` and + `Profile.overlaying`. This is the user-visible cutover; verify on simulator + (screenshots) against acceptance criteria 1-7. +10. `profile-table-cleanup` - delete `ProfileSyncCoordinator` and dead + identity-mirroring paths; de-scope `DBMemberProfile` reads. +11. `profile-table-drop-columns` - the gated `dropMemberProfileIdentityColumns` + migration. Shipped a release after PR9/10 are live so backfill has run for + all upgraders. + +PRs 9 and 11 are the two risk gates; everything before PR9 is invisible to users +and reversible by not landing PR9. + +## 13. Backwards and forwards compatibility + +The headline: this plan introduces no wire-format changes. Every protobuf in +`profile_messages.proto` (`ProfileUpdate`, `ProfileSnapshot`, `MemberProfile`, +`EncryptedProfileImageRef`) and the app-data `ConversationProfile` / +`EncryptedImageRef` in `conversation_custom_metadata.proto` are untouched. The +refactor changes where identity is stored locally and how it is rendered, not +what is sent between clients. So a mixed-version group works in both directions +and no client needs to upgrade in lockstep. + +### Inbound (updated client reads an old client's messages) + +Old clients keep sending `ProfileUpdate` / `ProfileSnapshot` exactly as today. +The updated client decodes them with the same codecs; the only change is that the +decoded data is routed through `ProfilesRepository.apply(event)` into `DBProfile` +/ `DBProfileAvatar` instead of `DBMemberProfile`. No existing field is +reinterpreted. In particular, today's avatar semantics are preserved verbatim: + +- A `ProfileUpdate` carrying an `encrypted_image` sets the avatar (authoritative). +- A `ProfileUpdate` with no `encrypted_image` clears the avatar (authoritative) - + the same behavior `StreamProcessor.processProfileUpdate` has today. +- A `ProfileSnapshot` entry fills blanks only and never overwrites a value the + subject authored - the same "skip if present" behavior, now expressed as the + merge's `profileSnapshot` precedence level. + +### Outbound (old client reads an updated client's messages) + +The updated client emits byte-identical `ProfileUpdate` / `ProfileSnapshot` +messages. The `ProfilePublisher` changes durability, ordering, and retry locally; +it does not change the wire bytes. Two outbound channels exist today and both must +be preserved so old clients keep resolving identity: + +1. The `ProfileUpdate` message sent to the group. +2. The app-data `ConversationProfile` write into group metadata + (`group.updateProfile(...)` today). + +The publisher's `ProfilePublishSession` must continue to do both. Dropping the +app-data write would regress old clients that read identity from app-data, so it +is a hard requirement, not an optimization. + +### Local-only additions (no wire exposure) + +`ProfileSource`, the reserved `avatarContentDigest` / `contentDigest` columns, the +`selfProfile` / `profileAvatarSource` / `profilePublishJob` tables, and the merge +machinery are all local. None is serialized into any message. They cannot affect +another client. + +### The one wire-affecting improvement, deliberately deferred + +The spec's "a name-only update must never blank an avatar" goal is the single +piece that cannot be done without a wire change, and it is out of scope here. +Today "no `encrypted_image` present" is overloaded to mean "clear the avatar," so +there is no way to distinguish a name-only update (should be silent on the avatar) +from an explicit removal (should clear it). Distinguishing them needs a new signal +on the wire - an explicit "avatar removed" tombstone field, distinct from "field +absent." That is a separate, additive, proto3-optional change (a sibling to +ADR 014/015): old clients ignore the new field and fall back to today's behavior, +so it is itself backward compatible. Until it ships, this plan keeps today's exact +semantics (absent image = clear), and the merge engine's `silent` avatar state is +reachable only from snapshot/app-data fill-blank paths, not from `ProfileUpdate`. +This means acceptance criterion 4 (name-only update never blanks an avatar) is +only fully met once that follow-up lands; this plan does not regress it relative +to today. Tracked as a follow-up in section 17. + +### Same-version, cross-device (the local user's own installs) + +`DBSelfProfile` and the publish queue are per-install local state. A second +install of the same user learns the user's identity the same way any client does: +from the `ProfileUpdate` / app-data the first install publishes. The backfill +seeds `DBSelfProfile` from existing local `DBMemberProfile` rows, so an upgrade in +place does not require a re-publish to recover self identity. + +### Upgrade ordering (local, not wire) + +The only ordering constraint is local and already covered in Risks: the gated +`dropMemberProfileIdentityColumns` migration (PR 11) must ship a release after the +backfill (PR 5/8) so every upgrader has run the backfill before the legacy columns +disappear. This is a local schema concern with no cross-client effect. + +## 14. Testing strategy + +- Unit (ConvosCore, macOS, no Docker): models, migration up/down, stores, + merge engine, repository with in-memory stores, publisher queue mechanics + (inject a fake `ProfilePublishSession`). Use `.mock` factories per CLAUDE.md. +- Integration (Docker XMTP node): inbound `apply` ordering and snapshot/update + precedence; publisher send round-trip; backfill from seeded `DBMemberProfile`. +- UI verification (simulator screenshots) for PR9 against acceptance criteria, + especially: identical avatar across surfaces, removal propagation, name-only + not blanking avatar, join-group renders members immediately. +- Add the `redundant-decrypt-on-join` counter (spec/ADR 014 gate) as a metric in + PR8 or PR9 so the digest follow-up has data, without building the digest. + +## 15. Risks and rollback + +- Backfill correctness is the top risk: keep it idempotent, floor-timestamped, + and gate the column-drop on its completion marker. If backfill is wrong, no + data is lost (legacy columns remain until PR11). +- Cutover (PR9) is the visible risk: it is a single PR that can be held or + reverted independently; everything beneath it is dormant infrastructure. +- Publisher durability: cached ciphertext + `sourceVersion` pin guarantee + exactly-once-per-conversation, restart-safe sends; covered by queue tests. +- Type-check budget: new SwiftUI bodies must hoist conditionals; review each + touched body in PR9. + +## 16. Open questions (carried from spec) + +- Keep the `profileAvatar` FK cascade in addition to the cleaner, or cleaner + alone. Design keeps both (cascade as defense in depth). +- Whether `DBSelfProfile` subsumes `DBMyProfile` / `MyGlobalProfileRepository` + or sits alongside during transition. Design: sit alongside through PR9, fold in + during PR10 cleanup. +- `profileSource` on the row vs derived at apply time. Design: persist on the row + (`DBProfile.profileSource` / `DBProfileAvatar.profileSource`) so merge + decisions survive restart without replaying events. + +## 17. Follow-up items (post-merge) + +Single source of truth for work deferred out of this PR. None of it blocks merge +(everything here is additive and the rendering choke point already sits on the +canonical tables), but the reader/writer migrations below must land before +`member_profile` can be cleared or dropped. + +### Completed in this PR (for context) +- [x] One avatar image per person across conversations, via the + `profileAvatarLatest` view joined through `DBConversationMember.avatarSlot` + (keyed by inboxId). Each avatar row is self-contained (url + salt + nonce + + key) from a shared conversation, so it decrypts anywhere. Pragmatic + alternative to the ADR 014 content-digest dedup. +- [x] Contact no longer overrides `Profile` name/image; the override entry points + (`Profile.overlaying`, `Contact.memberAwareResolver`, `.memberContactOverride`, + `memberNameOverride`) are neutered no-ops. +- [x] Contact photo renders from the canonical profile: `ContactAvatarView` + delegates to the reactive `InboxProfileAvatarView(inboxId:)` (subscribes to + `profilePublisher`, shares the inboxId-keyed image cache). +- [x] DM conversation-list preview photo is reactive: `ConversationAvatarView`'s + DM case routes through `InboxProfileAvatarView`, so a member's photo change + updates the list without opening the conversation. + +### Rendering: finish the move onto the reactive per-inbox path +Most rendering still flows through the choke-point GRDB snapshot reroute; only the +contact and DM-preview photos use the reactive publishers. Completing this makes +every surface live-update consistently and lets us delete the snapshot-baked +avatar plumbing. +- [ ] Migrate all remaining avatar/identity rendering onto `profilePublisher` / + `selfProfilePublisher`. Then delete the snapshot-baked plumbing: + `ConversationAvatarView`'s `.cachedImage(for: conversation)` path, the + per-conversation avatar baked into the list snapshot, and eventually the + `profileAvatarLatest` view once `displayAvatar(for: nil)` is the render path. +- [ ] Remove the now-redundant `.cachedImage(for: conversation)` load still + applied on `ConversationAvatarView`'s DM branch (the DM case renders via + `InboxProfileAvatarView`, so the outer conversation-image load is dead weight + per DM row). +- [ ] Eliminate the brief old-image flash on avatar update. On a same-identity URL + change the image cache deliberately shows the person's previous image (the + continuity hint) while the new encrypted image decrypts, so the recipient sees a + flash of the old photo. Fix: decrypt-and-warm the new image into `ImageCache` + before the observable canonical `DBProfileAvatar` write fires the re-render, and + realign `EncryptedImagePrefetcher` (still fed legacy `DBMemberProfile` rows) to + the canonical avatar. Pre-existing continuity-hint behavior, not introduced by + this refactor; the refactor only shifted the timing. +- [ ] If `MessagesRepositoryBenchmarkTests` regress from the per-row DM + observation, hoist to a single batched `profilesPublisher(inboxIds:)` at the + list level instead of one observation per visible row. +- [ ] Clustered group avatar not rendering: confirm whether groups that fall back + to a single default emoji are hitting a data gap. The rendering path + (`avatarType.clustered` -> `ClusteredAvatarView`) is intact; the cluster only + shows when `hasAnyAvatar` is true. `ProfileBackfill.backfillAvatar` skips any + legacy avatar failing `hasValidEncryptedAvatar` (missing salt/nonce/key), so + members with incomplete legacy crypto metadata get no canonical avatar and the + cluster reads empty. Needs runtime confirmation (single default emoji vs partial + cluster; migrated vs freshly created group) before fixing. + +### Migrate remaining `DBMemberProfile` readers to canonical +- [ ] `ContactSyncCoordinator.syncConversation` - builds contact snapshots per + member. +- [ ] `AssetRenewalURLCollector.collectRenewableAssets` - enumerates avatar assets + for renewal. +- [ ] `AgentTimezonePublisher.republishTimezoneForAgentConversations` - finds the + user's conversations. +- [ ] `AgentBuilderConnectionGrantReplayer.verifiedAgentInboxIds` - filters + verified agents (an agent reverified only in `DBProfile` is invisible here). + +### Migrate remaining `DBMemberProfile` write paths to canonical +Identity introduced only through these renders as "Somebody" until migrated. +- [ ] `InviteJoinRequestsManager` - joiner profile (currently via the + `ContactsWriter` mirror). +- [ ] `ConversationWriter.persist` appData gap-fill, and the appData-only member + case generally (member identity that lives only in group appData, never in a + ProfileUpdate/Snapshot). + +### Cold-start ordering (agent verification) +- [ ] `IncomingMessageWriter.bootstrapSenderProfile` reads `DBProfile`, but in + `BatchCatchUp` message persist runs before `ProfileInboundApplier` (detached), + so a verified agent's grant request can be dropped on cold-launch backlog + replay. Land the canonical write before the trusted-sender gate is checked. + Detailed writeup: `outputs/followup-agent-grant-coldstart-drop.md`. Pre-existing + on `dev`. + +### Self-profile consolidation +- [ ] Collapse `DBMyProfile` and `DBSelfProfile` into one authoritative self + source. Options: fold the editor's raw image bytes into the canonical path and + retire `DBMyProfile`, or keep `DBMyProfile` strictly as a local image-bytes + cache and make `DBSelfProfile` derived from it on every write. Removes the + backfill stopgap. + +### Contact / Profile deduplication +`Contact` is now largely redundant with `Profile`; identity should always come +from the `Profile` DB. +- [ ] Remove the neutered plumbing entirely: delete `Profile+ContactOverlay.swift` + (`overlaying`, `liveOverride`, `memberAwareResolver`) and the + `memberContactOverride` / `memberNameOverride` environment carriers, and drop + the dead resolver threading through the message view controller + list cells. + Point remaining contact-specific UI (`ContactDetailView` name/subtitle, contact + cards) directly at the `Profile` DB. +- [ ] Remove the name / image columns from the `contact` table (`displayName`, + `avatarURL`, `avatarSalt`, `avatarNonce`, `avatarKey`, `profileEmoji`, + agent-verification/template fields) once every contact-specific surface reads + from `Profile`. Keep relationship-only state (`addedAt`, block state, etc.). + +### Wire-format change (deferred): avatar deliberate-clear +- [ ] Add an explicit "avatar removed" tombstone field to `ProfileUpdate` / + `Snapshot`, distinct from "field absent," so a name-only update stays silent on + the avatar while a real removal clears it. Restores `.explicitClear` in + `ProfileInboundApplier` for the true-clear case and fully satisfies acceptance + criterion 4 (name-only update never blanks an avatar). Additive, proto3-optional, + backward compatible; sibling to ADR 014/015. Compat rationale in section 13. + +### Final gate +- [ ] Clear + drop `member_profile` (a `deleteAll` after backfill plus a + `DROP TABLE` migration). Only after every reader/writer above is migrated.