session-ios/SessionMessagingKit/Utilities/ProfileManager.swift
Morgan Pretty cf66edb723 Further work on SessionMessagingKit migrations
Added migrations for contacts and started working through thread migration (have contact and closed group threads migrating)
Deprecated usage of ECKeyPair in the migrations (want to be able to remove Curve25519Kit in the future)
2022-04-06 15:43:26 +10:00

352 lines
15 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
import PromiseKit
import SignalCoreKit
import SessionUtilitiesKit
public struct ProfileManager {
public enum Error: LocalizedError {
case avatarImageTooLarge
case avatarWriteFailed
case avatarEncryptionFailed
case avatarUploadFailed
case avatarUploadMaxFileSizeExceeded
var localizedDescription: String {
switch self {
case .avatarImageTooLarge: return "Avatar image too large."
case .avatarWriteFailed: return "Avatar write failed."
case .avatarEncryptionFailed: return "Avatar encryption failed."
case .avatarUploadFailed: return "Avatar upload failed."
case .avatarUploadMaxFileSizeExceeded: return "Maximum file size exceeded."
}
}
}
// The max bytes for a user's profile name, encoded in UTF8.
// Before encrypting and submitting we NULL pad the name data to this length.
private static let nameDataLength: UInt = 26
public static let maxAvatarDiameter: CGFloat = 640
private static var profileAvatarCache: Atomic<[String: UIImage]> = Atomic([:])
private static var currentAvatarDownloads: Atomic<Set<String>> = Atomic([])
// MARK: - Functions
public static func isToLong(profileName: String) -> Bool {
return ((profileName.data(using: .utf8)?.count ?? 0) > nameDataLength)
}
public static func profileAvatar(for id: String) -> UIImage? {
guard let profile: Profile = GRDBStorage.shared.read({ db in try Profile.fetchOne(db, id: id) }) else {
return nil
}
if let profileFileName: String = profile.profilePictureFileName, !profileFileName.isEmpty {
return loadProfileAvatar(for: profileFileName)
}
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
downloadAvatar(for: profile)
}
return nil
}
private static func loadProfileAvatar(for fileName: String) -> UIImage? {
if let cachedImage: UIImage = profileAvatarCache.wrappedValue[fileName] {
return cachedImage
}
guard
!fileName.isEmpty,
let data: Data = loadProfileData(with: fileName),
data.isValidImage,
let image: UIImage = UIImage(data: data)
else {
return nil
}
profileAvatarCache.mutate { $0[fileName] = image }
return image
}
private static func loadProfileData(with fileName: String) -> Data? {
let filePath: String = OWSUserProfile.profileAvatarFilepath(withFilename: fileName)
return try? Data(contentsOf: URL(fileURLWithPath: filePath))
}
// MARK: - Profile Encryption
private static func encryptProfileData(data: Data, key: OWSAES256Key) -> Data? {
guard key.keyData.count == kAES256_KeyByteLength else { return nil }
return Cryptography.encryptAESGCMProfileData(plainTextData: data, key: key)
}
private static func decryptProfileData(data: Data, key: OWSAES256Key) -> Data? {
guard key.keyData.count == kAES256_KeyByteLength else { return nil }
return Cryptography.decryptAESGCMProfileData(encryptedData: data, key: key)
}
// MARK: - Other Users' Profiles
public static func downloadAvatar(for profile: Profile, funcName: String = #function) {
guard !currentAvatarDownloads.wrappedValue.contains(profile.id) else {
// Download already in flight; ignore
return
}
guard
let profileUrlStringAtStart: String = profile.profilePictureUrl,
let profileUrlAtStart: URL = URL(string: profileUrlStringAtStart)
else {
SNLog("Skipping downloading avatar for \(profile.id) because url is not set")
return
}
guard
let fileId: UInt64 = UInt64(profileUrlAtStart.lastPathComponent),
let profileKeyAtStart: OWSAES256Key = profile.profileEncryptionKey,
profileKeyAtStart.keyData.count > 0
else {
return
}
let fileName: String = UUID().uuidString.appendingFileExtension("jpg")
let filePath: String = OWSUserProfile.profileAvatarFilepath(withFilename: fileName)
var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: funcName)
DispatchQueue.global(qos: .default).async {
OWSLogger.verbose("downloading profile avatar: \(profile.id)")
currentAvatarDownloads.mutate { $0.insert(profile.id) }
let useOldServer: Bool = (profileUrlStringAtStart.contains(FileServerAPIV2.oldServer))
FileServerAPIV2
.download(fileId, useOldServer: useOldServer)
.done { data in
currentAvatarDownloads.mutate { $0.remove(profile.id) }
GRDBStorage.shared.write { db in
guard let latestProfile: Profile = try Profile.fetchOne(db, id: profile.id) else {
return
}
guard
let latestProfileKey: OWSAES256Key = latestProfile.profileEncryptionKey,
!latestProfileKey.keyData.isEmpty,
latestProfileKey == profileKeyAtStart
else {
OWSLogger.warn("Ignoring avatar download for obsolete user profile.")
return
}
guard profileUrlStringAtStart == latestProfile.profilePictureUrl else {
OWSLogger.warn("Avatar url has changed during download.")
if latestProfile.profilePictureUrl?.isEmpty == false {
self.downloadAvatar(for: latestProfile)
}
return
}
guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else {
OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.")
return
}
try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic])
guard let image: UIImage = UIImage(contentsOfFile: filePath) else {
OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.")
return
}
try? latestProfile
.with(profilePictureFileName: .update(fileName))
.update(db)
profileAvatarCache.mutate { $0[fileName] = image }
}
// Redundant but without reading 'backgroundTask' it will warn that the variable
// isn't used
if backgroundTask != nil { backgroundTask = nil }
}
.retainUntilComplete()
}
}
// MARK: - Current User Profile
public static func updateLocal(
profileName: String,
avatarImage: UIImage?,
requiredSync: Bool,
success: (() -> ())? = nil,
failure: ((Error) -> ())? = nil
) {
DispatchQueue.global(qos: .default).async {
// If the profile avatar was updated or removed then encrypt with a new profile key
// to ensure that other users know that our profile picture was updated
let newProfileKey: OWSAES256Key = OWSAES256Key.generateRandom()
guard let avatarImage: UIImage = avatarImage else {
// If we have no image then we need to make sure to remove it from the profile
GRDBStorage.shared.writeAsync(
updates: { db in
let existingProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
OWSLogger.verbose(existingProfile.profilePictureUrl != nil ?
"Updating local profile on service with cleared avatar." :
"Updating local profile on service with no avatar."
)
try? existingProfile
.with(
name: profileName,
profilePictureUrl: nil,
profilePictureFileName: nil,
profileEncryptionKey: (existingProfile.profilePictureUrl != nil ?
.update(newProfileKey) :
.existing
)
)
.save(db)
// Remove any cached avatar image value
if let fileName: String = existingProfile.profilePictureFileName {
profileAvatarCache.mutate { $0[fileName] = nil }
}
},
completion: { _, _ in
SNLog("Successfully updated service with profile.")
DispatchQueue.main.async {
success?()
}
}
)
return
}
// If we have a new avatar image, we must first:
//
// * Encode it to JPEG.
// * Write it to disk.
// * Encrypt it
// * Upload it to asset service
// * Send asset service info to Signal Service
OWSLogger.verbose("Updating local profile on service with new avatar.")
let maxAvatarBytes: UInt = (5 * 1000 * 1000)
var image: UIImage = avatarImage
if image.size.width != maxAvatarDiameter || image.size.height != maxAvatarDiameter {
// To help ensure the user is being shown the same cropping of their avatar as
// everyone else will see, we want to be sure that the image was resized before this point.
SNLog("Avatar image should have been resized before trying to upload")
image = image.resizedImage(toFillPixelSize: CGSize(width: maxAvatarDiameter, height: maxAvatarDiameter))
}
guard let data: Data = image.jpegData(compressionQuality: 0.95) else {
DispatchQueue.main.async {
SNLog("Updating service with profile failed.")
failure?(.avatarWriteFailed)
}
return
}
guard data.count <= maxAvatarBytes else {
// Our avatar dimensions are so small that it's incredibly unlikely we wouldn't
// be able to fit our profile photo (eg. generating pure noise at our resolution
// compresses to ~200k)
DispatchQueue.main.async {
SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)")
SNLog("Updating service with profile failed.")
failure?(.avatarImageTooLarge)
}
return
}
let fileName: String = UUID().uuidString.appendingFileExtension("jpg")
let filePath: String = OWSUserProfile.profileAvatarFilepath(withFilename: fileName)
// Write the avatar to disk
do { try data.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) }
catch {
DispatchQueue.main.async {
SNLog("Updating service with profile failed.")
failure?(.avatarWriteFailed)
}
return
}
// Encrypt the avatar for upload
guard let encryptedAvatarData: Data = encryptProfileData(data: data, key: newProfileKey) else {
DispatchQueue.main.async {
SNLog("Updating service with profile failed.")
failure?(.avatarEncryptionFailed)
}
return
}
// Upload the avatar to the FileServer
FileServerAPIV2
.upload(encryptedAvatarData)
.done { fileId in
let downloadUrl: String = "\(FileServerAPIV2.server)/files/\(fileId)"
UserDefaults.standard[.lastProfilePictureUpload] = Date()
GRDBStorage.shared.writeAsync(
updates: { db in
try? Profile
.fetchOrCreateCurrentUser(db)
.with(
name: profileName,
profilePictureUrl: .update(downloadUrl),
profilePictureFileName: .update(fileName),
profileEncryptionKey: .update(newProfileKey)
)
.save(db)
},
completion: { _, _ in
// Update the cached avatar image value
profileAvatarCache.mutate { $0[fileName] = avatarImage }
DispatchQueue.main.async {
SNLog("Successfully updated service with profile.")
success?()
}
}
)
}
.recover { error in
DispatchQueue.main.async {
SNLog("Updating service with profile failed.")
let isMaxFileSizeExceeded: Bool = ((error as? FileServerAPIV2.Error) == FileServerAPIV2.Error.maxFileSizeExceeded)
failure?(isMaxFileSizeExceeded ?
.avatarUploadMaxFileSizeExceeded :
.avatarUploadFailed
)
}
}
.retainUntilComplete()
}
}
}
// MARK: - Objective-C Support
@objc(SMKProfileManager)
public class SMKProfileManager: NSObject {
@objc public static func profileAvatar(recipientId: String) -> UIImage? {
return ProfileManager.profileAvatar(for: recipientId)
}
@objc public static func updateLocal(profileName: String, avatarImage: UIImage?, requiresSync: Bool) {
ProfileManager.updateLocal(profileName: profileName, avatarImage: avatarImage, requiredSync: requiresSync)
}
}