// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import SessionUtil import SessionUtilitiesKit // MARK: - Size Restrictions public extension SessionUtil { static var libSessionMaxNameByteLength: Int { CONTACT_MAX_NAME_LENGTH } static var libSessionMaxNicknameByteLength: Int { CONTACT_MAX_NAME_LENGTH } static var libSessionMaxProfileUrlByteLength: Int { PROFILE_PIC_MAX_URL_LENGTH } } // MARK: - Contacts Handling internal extension SessionUtil { static let columnsRelatedToContacts: [ColumnExpression] = [ Contact.Columns.isApproved, Contact.Columns.isBlocked, Contact.Columns.didApproveMe, Profile.Columns.name, Profile.Columns.nickname, Profile.Columns.profilePictureUrl, Profile.Columns.profileEncryptionKey ] // MARK: - Incoming Changes static func handleContactsUpdate( _ db: Database, in conf: UnsafeMutablePointer?, mergeNeedsDump: Bool, latestConfigSentTimestampMs: Int64 ) throws { guard mergeNeedsDump else { return } guard conf != nil else { throw SessionUtilError.nilConfigObject } // The current users contact data is handled separately so exclude it if it's present (as that's // actually a bug) let userPublicKey: String = getUserHexEncodedPublicKey(db) let targetContactData: [String: ContactData] = try extractContacts( from: conf, latestConfigSentTimestampMs: latestConfigSentTimestampMs ).filter { $0.key != userPublicKey } // Since we don't sync 100% of the data stored against the contact and profile objects we // need to only update the data we do have to ensure we don't overwrite anything that doesn't // get synced try targetContactData .forEach { sessionId, data in // Note: We only update the contact and profile records if the data has actually changed // in order to avoid triggering UI updates for every thread on the home screen (the DB // observation system can't differ between update calls which do and don't change anything) let contact: Contact = Contact.fetchOrCreate(db, id: sessionId) let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) let profileNameShouldBeUpdated: Bool = ( !data.profile.name.isEmpty && profile.name != data.profile.name && profile.lastNameUpdate < data.profile.lastNameUpdate ) let profilePictureShouldBeUpdated: Bool = ( ( profile.profilePictureUrl != data.profile.profilePictureUrl || profile.profileEncryptionKey != data.profile.profileEncryptionKey ) && profile.lastProfilePictureUpdate < data.profile.lastProfilePictureUpdate ) if profileNameShouldBeUpdated || profile.nickname != data.profile.nickname || profilePictureShouldBeUpdated { try profile.save(db) try Profile .filter(id: sessionId) .updateAll( // Handling a config update so don't use `updateAllAndConfig` db, [ (!profileNameShouldBeUpdated ? nil : Profile.Columns.name.set(to: data.profile.name) ), (!profileNameShouldBeUpdated ? nil : Profile.Columns.lastNameUpdate.set(to: data.profile.lastNameUpdate) ), (profile.nickname == data.profile.nickname ? nil : Profile.Columns.nickname.set(to: data.profile.nickname) ), (profile.profilePictureUrl != data.profile.profilePictureUrl ? nil : Profile.Columns.profilePictureUrl.set(to: data.profile.profilePictureUrl) ), (profile.profileEncryptionKey != data.profile.profileEncryptionKey ? nil : Profile.Columns.profileEncryptionKey.set(to: data.profile.profileEncryptionKey) ), (!profilePictureShouldBeUpdated ? nil : Profile.Columns.lastProfilePictureUpdate.set(to: data.profile.lastProfilePictureUpdate) ) ].compactMap { $0 } ) } /// Since message requests have no reverse, we should only handle setting `isApproved` /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message /// swapping `isApproved` and `didApproveMe` to `false` if (contact.isApproved != data.contact.isApproved) || (contact.isBlocked != data.contact.isBlocked) || (contact.didApproveMe != data.contact.didApproveMe) { try contact.save(db) try Contact .filter(id: sessionId) .updateAll( // Handling a config update so don't use `updateAllAndConfig` db, [ (!data.contact.isApproved || contact.isApproved == data.contact.isApproved ? nil : Contact.Columns.isApproved.set(to: true) ), (contact.isBlocked == data.contact.isBlocked ? nil : Contact.Columns.isBlocked.set(to: data.contact.isBlocked) ), (!data.contact.didApproveMe || contact.didApproveMe == data.contact.didApproveMe ? nil : Contact.Columns.didApproveMe.set(to: true) ) ].compactMap { $0 } ) } /// If the contact's `hidden` flag doesn't match the visibility of their conversation then create/delete the /// associated contact conversation accordingly let threadInfo: PriorityVisibilityInfo? = try? SessionThread .filter(id: sessionId) .select(.id, .variant, .pinnedPriority, .shouldBeVisible) .asRequest(of: PriorityVisibilityInfo.self) .fetchOne(db) let threadExists: Bool = (threadInfo != nil) let updatedShouldBeVisible: Bool = SessionUtil.shouldBeVisible(priority: data.priority) /// If we are hiding the conversation then kick the user from it if it's currently open if !updatedShouldBeVisible { SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: [sessionId]) } /// Create the thread if it doesn't exist, otherwise just update it's state if !threadExists { try SessionThread( id: sessionId, variant: .contact, creationDateTimestamp: data.created, shouldBeVisible: updatedShouldBeVisible, pinnedPriority: data.priority ).save(db) } else { let changes: [ConfigColumnAssignment] = [ (threadInfo?.shouldBeVisible == updatedShouldBeVisible ? nil : SessionThread.Columns.shouldBeVisible.set(to: updatedShouldBeVisible) ), (threadInfo?.pinnedPriority == data.priority ? nil : SessionThread.Columns.pinnedPriority.set(to: data.priority) ) ].compactMap { $0 } try SessionThread .filter(id: sessionId) .updateAll( // Handling a config update so don't use `updateAllAndConfig` db, changes ) } } /// Delete any contact/thread records which aren't in the config message let syncedContactIds: [String] = targetContactData .map { $0.key } .appending(userPublicKey) let contactIdsToRemove: [String] = try Contact .filter(!syncedContactIds.contains(Contact.Columns.id)) .select(.id) .asRequest(of: String.self) .fetchAll(db) let threadIdsToRemove: [String] = try SessionThread .filter(!syncedContactIds.contains(SessionThread.Columns.id)) .filter(SessionThread.Columns.variant == SessionThread.Variant.contact) .filter(!SessionThread.Columns.id.like("\(SessionId.Prefix.blinded15.rawValue)%")) .filter(!SessionThread.Columns.id.like("\(SessionId.Prefix.blinded25.rawValue)%")) .select(.id) .asRequest(of: String.self) .fetchAll(db) /// When the user opens a brand new conversation this creates a "draft conversation" which has a hidden thread but no /// contact record, when we receive a contact update this "draft conversation" would be included in the /// `threadIdsToRemove` which would result in the user getting kicked from the screen and the thread removed, we /// want to avoid this (as it's essentially a bug) so find any conversations in this state and remove them from the list that /// will be pruned let threadT: TypedTableAlias = TypedTableAlias() let contactT: TypedTableAlias = TypedTableAlias() let draftConversationIds: [String] = try SQLRequest(""" SELECT \(threadT[.id]) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contactT[.id]) = \(threadT[.id]) WHERE ( \(SQL("\(threadT[.id]) IN \(threadIdsToRemove)")) AND \(contactT[.id]) IS NULL ) """).fetchAll(db) /// Consolidate the ids which should be removed let combinedIds: [String] = contactIdsToRemove .appending(contentsOf: threadIdsToRemove) .filter { !draftConversationIds.contains($0) } if !combinedIds.isEmpty { SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: combinedIds) try Contact .filter(ids: combinedIds) .deleteAll(db) // Also need to remove any 'nickname' values since they are associated to contact data try Profile .filter(ids: combinedIds) .updateAll( db, Profile.Columns.nickname.set(to: nil) ) // Delete the one-to-one conversations associated to the contact try SessionThread .deleteOrLeave( db, threadIds: combinedIds, threadVariant: .contact, groupLeaveType: .forced, calledFromConfigHandling: true ) try SessionUtil.remove(db, volatileContactIds: combinedIds) } } // MARK: - Outgoing Changes static func upsert( contactData: [SyncedContactInfo], in conf: UnsafeMutablePointer? ) throws { guard conf != nil else { throw SessionUtilError.nilConfigObject } // The current users contact data doesn't need to sync so exclude it, we also don't want to sync // blinded message requests so exclude those as well let userPublicKey: String = getUserHexEncodedPublicKey() let targetContacts: [SyncedContactInfo] = contactData .filter { $0.id != userPublicKey && SessionId(from: $0.id)?.prefix == .standard } // If we only updated the current user contact then no need to continue guard !targetContacts.isEmpty else { return } // Update the name try targetContacts .forEach { info in var sessionId: [CChar] = info.id.cArray.nullTerminated() var contact: contacts_contact = contacts_contact() guard contacts_get_or_construct(conf, &contact, &sessionId) else { /// It looks like there are some situations where this object might not get created correctly (and /// will throw due to the implicit unwrapping) as a result we put it in a guard and throw instead SNLog("Unable to upsert contact to SessionUtil: \(SessionUtil.lastError(conf))") throw SessionUtilError.getOrConstructFailedUnexpectedly } // Assign all properties to match the updated contact (if there is one) if let updatedContact: Contact = info.contact { contact.approved = updatedContact.isApproved contact.approved_me = updatedContact.didApproveMe contact.blocked = updatedContact.isBlocked // If we were given a `created` timestamp then set it to the min between the current // setting and the value (as long as the current setting isn't `0`) if let created: Int64 = info.created.map({ Int64(floor($0)) }) { contact.created = (contact.created > 0 ? min(contact.created, created) : created) } // Store the updated contact (needs to happen before variables go out of scope) contacts_set(conf, &contact) } // Update the profile data (if there is one - users we have sent a message request to may // not have profile info in certain situations) if let updatedProfile: Profile = info.profile { let oldAvatarUrl: String? = String(libSessionVal: contact.profile_pic.url) let oldAvatarKey: Data? = Data( libSessionVal: contact.profile_pic.key, count: ProfileManager.avatarAES256KeyByteLength ) contact.name = updatedProfile.name.toLibSession() contact.nickname = updatedProfile.nickname.toLibSession() contact.profile_pic.url = updatedProfile.profilePictureUrl.toLibSession() contact.profile_pic.key = updatedProfile.profileEncryptionKey.toLibSession() // Download the profile picture if needed (this can be triggered within // database reads/writes so dispatch the download to a separate queue to // prevent blocking) if oldAvatarUrl != (updatedProfile.profilePictureUrl ?? "") || oldAvatarKey != (updatedProfile.profileEncryptionKey ?? Data(repeating: 0, count: ProfileManager.avatarAES256KeyByteLength)) { DispatchQueue.global(qos: .background).async { ProfileManager.downloadAvatar(for: updatedProfile) } } // Store the updated contact (needs to happen before variables go out of scope) contacts_set(conf, &contact) } // Store the updated contact (can't be sure if we made any changes above) contact.priority = (info.priority ?? contact.priority) contacts_set(conf, &contact) } } } // MARK: - Outgoing Changes internal extension SessionUtil { static func updatingContacts(_ db: Database, _ updated: [T]) throws -> [T] { guard let updatedContacts: [Contact] = updated as? [Contact] else { throw StorageError.generic } // The current users contact data doesn't need to sync so exclude it, we also don't want to sync // blinded message requests so exclude those as well let userPublicKey: String = getUserHexEncodedPublicKey(db) let targetContacts: [Contact] = updatedContacts .filter { $0.id != userPublicKey && SessionId(from: $0.id)?.prefix == .standard } // If we only updated the current user contact then no need to continue guard !targetContacts.isEmpty else { return updated } try SessionUtil.performAndPushChange( db, for: .contacts, publicKey: userPublicKey ) { conf in // When inserting new contacts (or contacts with invalid profile data) we want // to add any valid profile information we have so identify if any of the updated // contacts are new/invalid, and if so, fetch any profile data we have for them let newContactIds: [String] = targetContacts .compactMap { contactData -> String? in var cContactId: [CChar] = contactData.id.cArray.nullTerminated() var contact: contacts_contact = contacts_contact() guard contacts_get(conf, &contact, &cContactId), String(libSessionVal: contact.name, nullIfEmpty: true) != nil else { return contactData.id } return nil } let newProfiles: [String: Profile] = try Profile .fetchAll(db, ids: newContactIds) .reduce(into: [:]) { result, next in result[next.id] = next } // Upsert the updated contact data try SessionUtil .upsert( contactData: targetContacts .map { contact in SyncedContactInfo( id: contact.id, contact: contact, profile: newProfiles[contact.id] ) }, in: conf ) } return updated } static func updatingProfiles(_ db: Database, _ updated: [T]) throws -> [T] { guard let updatedProfiles: [Profile] = updated as? [Profile] else { throw StorageError.generic } // We should only sync profiles which are associated to contact data to avoid including profiles // for random people in community conversations so filter out any profiles which don't have an // associated contact let existingContactIds: [String] = (try? Contact .filter(ids: updatedProfiles.map { $0.id }) .select(.id) .asRequest(of: String.self) .fetchAll(db)) .defaulting(to: []) // If none of the profiles are associated with existing contacts then ignore the changes (no need // to do a config sync) guard !existingContactIds.isEmpty else { return updated } // Get the user public key (updating their profile is handled separately let userPublicKey: String = getUserHexEncodedPublicKey(db) let targetProfiles: [Profile] = updatedProfiles .filter { $0.id != userPublicKey && SessionId(from: $0.id)?.prefix == .standard && existingContactIds.contains($0.id) } // Update the user profile first (if needed) if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userPublicKey }) { try SessionUtil.performAndPushChange( db, for: .userProfile, publicKey: userPublicKey ) { conf in try SessionUtil.update( profile: updatedUserProfile, in: conf ) } } try SessionUtil.performAndPushChange( db, for: .contacts, publicKey: userPublicKey ) { conf in try SessionUtil .upsert( contactData: targetProfiles .map { SyncedContactInfo(id: $0.id, profile: $0) }, in: conf ) } return updated } } // MARK: - External Outgoing Changes public extension SessionUtil { static func hide(_ db: Database, contactIds: [String]) throws { try SessionUtil.performAndPushChange( db, for: .contacts, publicKey: getUserHexEncodedPublicKey(db) ) { conf in // Mark the contacts as hidden try SessionUtil.upsert( contactData: contactIds .map { SyncedContactInfo( id: $0, priority: SessionUtil.hiddenPriority ) }, in: conf ) } } static func remove(_ db: Database, contactIds: [String]) throws { guard !contactIds.isEmpty else { return } try SessionUtil.performAndPushChange( db, for: .contacts, publicKey: getUserHexEncodedPublicKey(db) ) { conf in contactIds.forEach { sessionId in var cSessionId: [CChar] = sessionId.cArray.nullTerminated() // Don't care if the contact doesn't exist contacts_erase(conf, &cSessionId) } } } } // MARK: - SyncedContactInfo extension SessionUtil { struct SyncedContactInfo { let id: String let contact: Contact? let profile: Profile? let priority: Int32? let created: TimeInterval? init( id: String, contact: Contact? = nil, profile: Profile? = nil, priority: Int32? = nil, created: TimeInterval? = nil ) { self.id = id self.contact = contact self.profile = profile self.priority = priority self.created = created } } } // MARK: - ContactData private struct ContactData { let contact: Contact let profile: Profile let priority: Int32 let created: TimeInterval } // MARK: - ThreadCount private struct ThreadCount: Codable, FetchableRecord { let id: String let interactionCount: Int } // MARK: - Convenience private extension SessionUtil { static func extractContacts( from conf: UnsafeMutablePointer?, latestConfigSentTimestampMs: Int64 ) throws -> [String: ContactData] { var infiniteLoopGuard: Int = 0 var result: [String: ContactData] = [:] var contact: contacts_contact = contacts_contact() let contactIterator: UnsafeMutablePointer = contacts_iterator_new(conf) while !contacts_iterator_done(contactIterator, &contact) { try SessionUtil.checkLoopLimitReached(&infiniteLoopGuard, for: .contacts) let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) } .map { CChar($0) } .nullTerminated() ) let contactResult: Contact = Contact( id: contactId, isApproved: contact.approved, isBlocked: contact.blocked, didApproveMe: contact.approved_me ) let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true) let profileResult: Profile = Profile( id: contactId, name: String(libSessionVal: contact.name), lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000), nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true), profilePictureUrl: profilePictureUrl, profileEncryptionKey: (profilePictureUrl == nil ? nil : Data( libSessionVal: contact.profile_pic.key, count: ProfileManager.avatarAES256KeyByteLength ) ), lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000) ) result[contactId] = ContactData( contact: contactResult, profile: profileResult, priority: contact.priority, created: TimeInterval(contact.created) ) contacts_iterator_advance(contactIterator) } contacts_iterator_free(contactIterator) // Need to free the iterator return result } }