mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			588 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			588 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			Swift
		
	
// 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<config_object>?,
 | 
						|
        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] = 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)
 | 
						|
 | 
						|
                switch (updatedShouldBeVisible, threadExists) {
 | 
						|
                    case (false, true):
 | 
						|
                        SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: [sessionId])
 | 
						|
                        
 | 
						|
                        try SessionThread
 | 
						|
                            .deleteOrLeave(
 | 
						|
                                db,
 | 
						|
                                threadId: sessionId,
 | 
						|
                                threadVariant: .contact,
 | 
						|
                                groupLeaveType: .forced,
 | 
						|
                                calledFromConfigHandling: true
 | 
						|
                            )
 | 
						|
                        
 | 
						|
                    case (true, false):
 | 
						|
                        try SessionThread(
 | 
						|
                            id: sessionId,
 | 
						|
                            variant: .contact,
 | 
						|
                            creationDateTimestamp: data.created,
 | 
						|
                            shouldBeVisible: true,
 | 
						|
                            pinnedPriority: data.priority
 | 
						|
                        ).save(db)
 | 
						|
                        
 | 
						|
                    case (true, true):
 | 
						|
                        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
 | 
						|
                            )
 | 
						|
                        
 | 
						|
                    case (false, false): break
 | 
						|
                }
 | 
						|
            }
 | 
						|
        
 | 
						|
        /// 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.blinded.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<SessionThread> = TypedTableAlias()
 | 
						|
        let contactT: TypedTableAlias<Contact> = TypedTableAlias()
 | 
						|
        let draftConversationIds: [String] = try SQLRequest<String>("""
 | 
						|
            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<config_object>?
 | 
						|
    ) 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<T>(_ 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<T>(_ 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<config_object>?,
 | 
						|
        latestConfigSentTimestampMs: Int64
 | 
						|
    ) -> [String: ContactData] {
 | 
						|
        var result: [String: ContactData] = [:]
 | 
						|
        var contact: contacts_contact = contacts_contact()
 | 
						|
        let contactIterator: UnsafeMutablePointer<contacts_iterator> = contacts_iterator_new(conf)
 | 
						|
        
 | 
						|
        while !contacts_iterator_done(contactIterator, &contact) {
 | 
						|
            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
 | 
						|
    }
 | 
						|
}
 |