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.
241 lines
12 KiB
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
|
|
)
|
|
}
|
|
}
|
|
}
|