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.
session-ios/SessionMessagingKit/Utilities/Profile+CurrentUser.swift

241 lines
12 KiB
Swift

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import UIKit.UIImage
import GRDB
import SessionUtilitiesKit
// MARK: - Log.Category
private extension Log.Category {
static let profile: Log.Category = .create("Profile", defaultLevel: .info)
}
// MARK: - Profile Updates
public extension Profile {
enum DisplayNameUpdate {
case none
case contactUpdate(String?)
case currentUserUpdate(String?)
}
static func isTooLong(profileName: String) -> Bool {
/// String.utf8CString will include the null terminator (Int8)0 as the end of string buffer.
/// When the string is exactly 100 bytes String.utf8CString.count will be 101.
/// However in LibSession, the Contact C API supports 101 characters in order to account for
/// the null terminator - char name[101]. So it is OK to use String.utf8.count
return (profileName.utf8CString.count > LibSession.sizeMaxNameBytes)
}
static func updateLocal(
queue: DispatchQueue,
displayNameUpdate: DisplayNameUpdate = .none,
displayPictureUpdate: DisplayPictureManager.Update = .none,
success: ((Database) throws -> ())? = nil,
failure: ((DisplayPictureError) -> ())? = nil,
using dependencies: Dependencies
) {
let userSessionId: SessionId = dependencies[cache: .general].sessionId
let isRemovingAvatar: Bool = {
switch displayPictureUpdate {
case .currentUserRemove: return true
default: return false
}
}()
switch displayPictureUpdate {
case .contactRemove, .contactUpdateTo, .groupRemove, .groupUpdateTo, .groupUploadImageData:
failure?(DisplayPictureError.invalidCall)
case .none, .currentUserRemove, .currentUserUpdateTo:
dependencies[singleton: .storage].writeAsync { db in
if isRemovingAvatar {
let existingProfileUrl: String? = try Profile
.filter(id: userSessionId.hexString)
.select(.profilePictureUrl)
.asRequest(of: String.self)
.fetchOne(db)
let existingProfileFileName: String? = try Profile
.filter(id: userSessionId.hexString)
.select(.profilePictureFileName)
.asRequest(of: String.self)
.fetchOne(db)
// Remove any cached avatar image value
if let fileName: String = existingProfileFileName {
dependencies.mutate(cache: .displayPicture) { $0.imageData[fileName] = nil }
}
switch existingProfileUrl {
case .some: Log.verbose(.profile, "Updating local profile on service with cleared avatar.")
case .none: Log.verbose(.profile, "Updating local profile on service with no avatar.")
}
}
try Profile.updateIfNeeded(
db,
publicKey: userSessionId.hexString,
displayNameUpdate: displayNameUpdate,
displayPictureUpdate: displayPictureUpdate,
sentTimestamp: dependencies.dateNow.timeIntervalSince1970,
calledFromConfig: nil,
using: dependencies
)
Log.info(.profile, "Successfully updated user profile.")
try success?(db)
}
case .currentUserUploadImageData(let data):
DisplayPictureManager.prepareAndUploadDisplayPicture(
queue: queue,
imageData: data,
success: { downloadUrl, fileName, newProfileKey in
dependencies[singleton: .storage].writeAsync { db in
try Profile.updateIfNeeded(
db,
publicKey: userSessionId.hexString,
displayNameUpdate: displayNameUpdate,
displayPictureUpdate: .currentUserUpdateTo(
url: downloadUrl,
key: newProfileKey,
fileName: fileName
),
sentTimestamp: dependencies.dateNow.timeIntervalSince1970,
calledFromConfig: nil,
using: dependencies
)
dependencies[defaults: .standard, key: .lastProfilePictureUpload] = dependencies.dateNow
Log.info(.profile, "Successfully updated user profile.")
try success?(db)
}
},
failure: failure,
using: dependencies
)
}
}
static func updateIfNeeded(
_ db: Database,
publicKey: String,
displayNameUpdate: DisplayNameUpdate = .none,
displayPictureUpdate: DisplayPictureManager.Update,
blocksCommunityMessageRequests: Bool? = nil,
sentTimestamp: TimeInterval,
calledFromConfig configTriggeringChange: ConfigDump.Variant?,
using dependencies: Dependencies
) throws {
let isCurrentUser = (publicKey == dependencies[cache: .general].sessionId.hexString)
let profile: Profile = Profile.fetchOrCreate(db, id: publicKey)
var profileChanges: [ConfigColumnAssignment] = []
/// There were some bugs (somewhere) where some of these timestamps valid could be in seconds or milliseconds so we need to try to
/// detect this and convert it to proper seconds (if we don't then we will never update the profile)
func convertToSections(_ maybeValue: Double?) -> TimeInterval {
guard let value: Double = maybeValue else { return 0 }
if value > 9_000_000_000_000 { // Microseconds
return (value / 1_000_000)
} else if value > 9_000_000_000 { // Milliseconds
return (value / 1000)
}
return TimeInterval(value) // Seconds
}
// Name
// FIXME: This 'lastNameUpdate' approach is buggy - we should have a timestamp on the ConvoInfoVolatile
switch (displayNameUpdate, isCurrentUser, (sentTimestamp > convertToSections(profile.lastNameUpdate))) {
case (.none, _, _): break
case (.currentUserUpdate(let name), true, _), (.contactUpdate(let name), false, true):
guard let name: String = name, !name.isEmpty, name != profile.name else { break }
profileChanges.append(Profile.Columns.name.set(to: name))
profileChanges.append(Profile.Columns.lastNameUpdate.set(to: sentTimestamp))
// Don't want profiles in messages to modify the current users profile info so ignore those cases
default: break
}
// Blocks community message requests flag
if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests, sentTimestamp > convertToSections(profile.lastBlocksCommunityMessageRequests) {
profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: blocksCommunityMessageRequests))
profileChanges.append(Profile.Columns.lastBlocksCommunityMessageRequests.set(to: sentTimestamp))
}
// Profile picture & profile key
switch (displayPictureUpdate, isCurrentUser) {
case (.none, _): break
case (.currentUserUploadImageData, _), (.groupRemove, _), (.groupUpdateTo, _):
preconditionFailure("Invalid options for this function")
case (.contactRemove, false), (.currentUserRemove, true):
profileChanges.append(Profile.Columns.profilePictureUrl.set(to: nil))
profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: nil))
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil))
profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp))
case (.contactUpdateTo(let url, let key, let fileName), false),
(.currentUserUpdateTo(let url, let key, let fileName), true):
var avatarNeedsDownload: Bool = false
if url != profile.profilePictureUrl {
profileChanges.append(Profile.Columns.profilePictureUrl.set(to: url))
avatarNeedsDownload = true
}
if key != profile.profileEncryptionKey && key.count == DisplayPictureManager.aes256KeyByteLength {
profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: key))
}
// Profile filename (this isn't synchronized between devices)
if let fileName: String = fileName {
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: fileName))
}
// If we have already downloaded the image then no need to download it again
let maybeFilePath: String? = try? DisplayPictureManager.filepath(
for: fileName.defaulting(to: DisplayPictureManager.generateFilename(for: url, using: dependencies)),
using: dependencies
)
if avatarNeedsDownload, let filePath: String = maybeFilePath, !dependencies[singleton: .fileManager].fileExists(atPath: filePath) {
dependencies[singleton: .jobRunner].add(
db,
job: Job(
variant: .displayPictureDownload,
shouldBeUnique: true,
details: DisplayPictureDownloadJob.Details(
target: .profile(id: profile.id, url: url, encryptionKey: key),
timestamp: sentTimestamp
)
),
canStartJob: dependencies[singleton: .appContext].isMainApp
)
}
// Update the 'lastProfilePictureUpdate' timestamp for either external or local changes
profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp))
// Don't want profiles in messages to modify the current users profile info so ignore those cases
default: break
}
// Persist any changes
if !profileChanges.isEmpty {
try profile.upsert(db)
try Profile
.filter(id: publicKey)
.updateAllAndConfig(
db,
profileChanges,
calledFromConfig: configTriggeringChange,
using: dependencies
)
}
}
}