session-ios/SessionMessagingKit/LibSessionUtil/Config Handling/SessionUtil+Contacts.swift

367 lines
18 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtil
import SessionUtilitiesKit
internal extension SessionUtil {
// MARK: - Incoming Changes
static func handleContactsUpdate(
_ db: Database,
in atomicConf: Atomic<UnsafeMutablePointer<config_object>?>,
needsDump: Bool
) throws {
typealias ContactData = [String: (contact: Contact, profile: Profile)]
guard needsDump else { return }
guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject }
// Since we are doing direct memory manipulation we are using an `Atomic` type which has
// blocking access in it's `mutate` closure
let contactData: ContactData = atomicConf.mutate { conf -> ContactData in
var contactData: 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 profileResult: Profile = Profile(
id: contactId,
name: (contact.name.map { String(cString: $0) } ?? ""),
nickname: contact.nickname.map { String(cString: $0) },
profilePictureUrl: contact.profile_pic.url.map { String(cString: $0) },
profileEncryptionKey: (contact.profile_pic.key != nil && contact.profile_pic.keylen > 0 ?
Data(bytes: contact.profile_pic.key, count: contact.profile_pic.keylen) :
nil
)
)
contactData[contactId] = (contactResult, profileResult)
contacts_iterator_advance(contactIterator)
}
contacts_iterator_free(contactIterator) // Need to free the iterator
return contactData
}
// 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()
let targetContactData: ContactData = contactData.filter { $0.key != userPublicKey }
// If we only updated the current user contact then no need to continue
guard !targetContactData.isEmpty else { return }
// 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)
if
(!data.profile.name.isEmpty && profile.name != data.profile.name) ||
profile.nickname != data.profile.nickname ||
profile.profilePictureUrl != data.profile.profilePictureUrl ||
profile.profileEncryptionKey != data.profile.profileEncryptionKey
{
try profile.save(db)
try Profile
.filter(id: sessionId)
.updateAll( // Handling a config update so don't use `updateAllAndConfig`
db,
[
(data.profile.name.isEmpty || profile.name == data.profile.name ? nil :
Profile.Columns.name.set(to: data.profile.name)
),
(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)
)
].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 ? nil :
Contact.Columns.isApproved.set(to: true)
),
Contact.Columns.isBlocked.set(to: data.contact.isBlocked),
(!data.contact.didApproveMe ? nil :
Contact.Columns.didApproveMe.set(to: true)
)
].compactMap { $0 }
)
}
}
}
// MARK: - Outgoing Changes
static func upsert(
contactData: [(id: String, contact: Contact?, profile: Profile?)],
in atomicConf: Atomic<UnsafeMutablePointer<config_object>?>
) throws -> ConfResult {
guard atomicConf.wrappedValue != nil else { throw SessionUtilError.nilConfigObject }
// The current users contact data doesn't need to sync so exclude it
let userPublicKey: String = getUserHexEncodedPublicKey()
let targetContacts: [(id: String, contact: Contact?, profile: Profile?)] = contactData
.filter { $0.id != userPublicKey }
// If we only updated the current user contact then no need to continue
guard !targetContacts.isEmpty else { return ConfResult(needsPush: false, needsDump: false) }
// Since we are doing direct memory manipulation we are using an `Atomic` type which has
// blocking access in it's `mutate` closure
return atomicConf.mutate { conf in
// Update the name
targetContacts
.forEach { (id, maybeContact, maybeProfile) in
var sessionId: [CChar] = id
.bytes
.map { CChar(bitPattern: $0) }
var contact: contacts_contact = contacts_contact()
guard contacts_get_or_create(conf, &contact, &sessionId) else {
SNLog("Unable to upsert contact from Config Message")
return
}
// Assign all properties to match the updated contact (if there is one)
if let updatedContact: Contact = maybeContact {
contact.approved = updatedContact.isApproved
contact.approved_me = updatedContact.didApproveMe
contact.blocked = updatedContact.isBlocked
}
// Update the profile data (if there is one)
if let updatedProfile: Profile = maybeProfile {
/// Users we have sent a message request to may not have profile info in certain situations
///
/// Note: We **MUST** store these in local variables rather than access them directly or they won't
/// exist in memory long enough to actually be assigned in the C type
let updatedName: [CChar]? = (updatedProfile.name.isEmpty ?
nil :
updatedProfile.name
.bytes
.map { CChar(bitPattern: $0) }
)
let updatedNickname: [CChar]? = updatedProfile.nickname?
.bytes
.map { CChar(bitPattern: $0) }
let updatedAvatarUrl: [CChar]? = updatedProfile.profilePictureUrl?
.bytes
.map { CChar(bitPattern: $0) }
let updatedAvatarKey: [UInt8]? = updatedProfile.profileEncryptionKey?
.bytes
let oldAvatarUrl: String? = contact.profile_pic.url.map { String(cString: $0) }
let oldAvatarKey: Data? = (contact.profile_pic.key != nil && contact.profile_pic.keylen > 0 ?
Data(bytes: contact.profile_pic.key, count: contact.profile_pic.keylen) :
nil
)
updatedName?.withUnsafeBufferPointer { contact.name = $0.baseAddress }
(updatedNickname == nil ?
contact.nickname = nil :
updatedNickname?.withUnsafeBufferPointer { contact.nickname = $0.baseAddress }
)
(updatedAvatarUrl == nil ?
contact.profile_pic.url = nil :
updatedAvatarUrl?.withUnsafeBufferPointer {
contact.profile_pic.url = $0.baseAddress
}
)
(updatedAvatarKey == nil ?
contact.profile_pic.key = nil :
updatedAvatarKey?.withUnsafeBufferPointer {
contact.profile_pic.key = $0.baseAddress
}
)
contact.profile_pic.keylen = (updatedAvatarKey?.count ?? 0)
// Download the profile picture if needed
if oldAvatarUrl != updatedProfile.profilePictureUrl || oldAvatarKey != updatedProfile.profileEncryptionKey {
ProfileManager.downloadAvatar(for: updatedProfile)
}
}
// Store the updated contact
contacts_set(conf, &contact)
}
return ConfResult(
needsPush: config_needs_push(conf),
needsDump: config_needs_dump(conf)
)
}
}
}
// MARK: - Convenience
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
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let targetContacts: [Contact] = updatedContacts.filter { $0.id != userPublicKey }
// If we only updated the current user contact then no need to continue
guard !targetContacts.isEmpty else { return updated }
db.afterNextTransaction { db in
do {
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = SessionUtil.config(
for: .contacts,
publicKey: userPublicKey
)
let result: ConfResult = try SessionUtil
.upsert(
contactData: targetContacts.map { (id: $0.id, contact: $0, profile: nil) },
in: atomicConf
)
// If we don't need to dump the data the we can finish early
guard result.needsDump else { return }
try SessionUtil.saveState(
db,
keepingExistingMessageHashes: true,
configDump: try atomicConf.mutate { conf in
try SessionUtil.createDump(
conf: conf,
for: .contacts,
publicKey: userPublicKey,
messageHashes: nil
)
}
)
}
catch {
SNLog("[libSession-util] Failed to dump updated data")
}
}
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)
db.afterNextTransaction { db in
do {
// Update the user profile first (if needed)
if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userPublicKey }) {
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = SessionUtil.config(
for: .userProfile,
publicKey: userPublicKey
)
let result: ConfResult = try SessionUtil.update(
profile: updatedUserProfile,
in: atomicConf
)
if result.needsDump {
try SessionUtil.saveState(
db,
keepingExistingMessageHashes: true,
configDump: try atomicConf.mutate { conf in
try SessionUtil.createDump(
conf: conf,
for: .userProfile,
publicKey: userPublicKey,
messageHashes: nil
)
}
)
}
}
// Then update other contacts
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = SessionUtil.config(
for: .contacts,
publicKey: userPublicKey
)
let result: ConfResult = try SessionUtil
.upsert(
contactData: updatedProfiles
.filter { $0.id != userPublicKey }
.map { (id: $0.id, contact: nil, profile: $0) },
in: atomicConf
)
// If we don't need to dump the data the we can finish early
guard result.needsDump else { return }
try SessionUtil.saveState(
db,
keepingExistingMessageHashes: true,
configDump: try atomicConf.mutate { conf in
try SessionUtil.createDump(
conf: conf,
for: .contacts,
publicKey: userPublicKey,
messageHashes: nil
)
}
)
}
catch {
SNLog("[libSession-util] Failed to dump updated data")
}
}
return updated
}
}