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.
		
		
		
		
		
			
		
			
				
	
	
		
			403 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			403 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import GRDB
 | |
| import PromiseKit
 | |
| import SignalCoreKit
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| public struct ProfileManager {
 | |
|     // 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 = 64
 | |
|     public static let maxAvatarDiameter: CGFloat = 640
 | |
|     
 | |
|     private static var profileAvatarCache: Atomic<[String: Data]> = 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(_ db: Database? = nil, id: String) -> Data? {
 | |
|         guard let db: Database = db else {
 | |
|             return Storage.shared.read { db in profileAvatar(db, id: id) }
 | |
|         }
 | |
|         guard let profile: Profile = try? Profile.fetchOne(db, id: id) else { return nil }
 | |
|         
 | |
|         return profileAvatar(profile: profile)
 | |
|     }
 | |
|     
 | |
|     public static func profileAvatar(profile: Profile) -> Data? {
 | |
|         if let profileFileName: String = profile.profilePictureFileName, !profileFileName.isEmpty {
 | |
|             return loadProfileAvatar(for: profileFileName, profile: profile)
 | |
|         }
 | |
|         
 | |
|         if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
 | |
|             downloadAvatar(for: profile)
 | |
|         }
 | |
|         
 | |
|         return nil
 | |
|     }
 | |
|     
 | |
|     private static func loadProfileAvatar(for fileName: String, profile: Profile) -> Data? {
 | |
|         if let cachedImageData: Data = profileAvatarCache.wrappedValue[fileName] {
 | |
|             return cachedImageData
 | |
|         }
 | |
|         
 | |
|         guard
 | |
|             !fileName.isEmpty,
 | |
|             let data: Data = loadProfileData(with: fileName),
 | |
|             data.isValidImage
 | |
|         else {
 | |
|             // If we can't load the avatar or it's an invalid/corrupted image then clear out
 | |
|             // the 'profilePictureFileName' and try to re-download
 | |
|             Storage.shared.writeAsync(
 | |
|                 updates: { db in
 | |
|                     _ = try? Profile
 | |
|                         .filter(id: profile.id)
 | |
|                         .updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil))
 | |
|                 },
 | |
|                 completion: { _, _ in
 | |
|                     // Try to re-download the avatar if it has a URL
 | |
|                     if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
 | |
|                         downloadAvatar(for: profile)
 | |
|                     }
 | |
|                 }
 | |
|             )
 | |
|             return nil
 | |
|         }
 | |
|     
 | |
|         profileAvatarCache.mutate { $0[fileName] = data }
 | |
|         return data
 | |
|     }
 | |
|     
 | |
|     private static func loadProfileData(with fileName: String) -> Data? {
 | |
|         let filePath: String = ProfileManager.profileAvatarFilepath(filename: 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: - File Paths
 | |
|     
 | |
|     public static let sharedDataProfileAvatarsDirPath: String = {
 | |
|         let path: String = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath())
 | |
|             .appendingPathComponent("ProfileAvatars")
 | |
|             .path
 | |
|         OWSFileSystem.ensureDirectoryExists(path)
 | |
|         
 | |
|         return path
 | |
|     }()
 | |
|     
 | |
|     private static let profileAvatarsDirPath: String = {
 | |
|         let path: String = ProfileManager.sharedDataProfileAvatarsDirPath
 | |
|         OWSFileSystem.ensureDirectoryExists(path)
 | |
|         
 | |
|         return path
 | |
|     }()
 | |
|     
 | |
|     public static func profileAvatarFilepath(_ db: Database? = nil, id: String) -> String? {
 | |
|         guard let db: Database = db else {
 | |
|             return Storage.shared.read { db in profileAvatarFilepath(db, id: id) }
 | |
|         }
 | |
|         
 | |
|         let maybeFileName: String? = try? Profile
 | |
|             .filter(id: id)
 | |
|             .select(.profilePictureFileName)
 | |
|             .asRequest(of: String.self)
 | |
|             .fetchOne(db)
 | |
|         
 | |
|         return maybeFileName.map { ProfileManager.profileAvatarFilepath(filename: $0) }
 | |
|     }
 | |
|     
 | |
|     public static func profileAvatarFilepath(filename: String) -> String {
 | |
|         guard !filename.isEmpty else { return "" }
 | |
|         
 | |
|         return URL(fileURLWithPath: sharedDataProfileAvatarsDirPath)
 | |
|             .appendingPathComponent(filename)
 | |
|             .path
 | |
|     }
 | |
|     
 | |
|     public static func resetProfileStorage() {
 | |
|         try? FileManager.default.removeItem(atPath: ProfileManager.profileAvatarsDirPath)
 | |
|     }
 | |
|     
 | |
|     // 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 else {
 | |
|             SNLog("Skipping downloading avatar for \(profile.id) because url is not set")
 | |
|             return
 | |
|         }
 | |
|         guard
 | |
|             let fileId: String = Attachment.fileId(for: profileUrlStringAtStart),
 | |
|             let profileKeyAtStart: OWSAES256Key = profile.profileEncryptionKey,
 | |
|             profileKeyAtStart.keyData.count > 0
 | |
|         else {
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let queue: DispatchQueue = DispatchQueue.global(qos: .default)
 | |
|         let fileName: String = UUID().uuidString.appendingFileExtension("jpg")
 | |
|         let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
 | |
|         var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: funcName)
 | |
|         
 | |
|         queue.async {
 | |
|             OWSLogger.verbose("downloading profile avatar: \(profile.id)")
 | |
|             currentAvatarDownloads.mutate { $0.insert(profile.id) }
 | |
|             
 | |
|             let useOldServer: Bool = (profileUrlStringAtStart.contains(FileServerAPI.oldServer))
 | |
|             
 | |
|             FileServerAPI
 | |
|                 .download(fileId, useOldServer: useOldServer)
 | |
|                 .done(on: queue) { data in
 | |
|                     currentAvatarDownloads.mutate { $0.remove(profile.id) }
 | |
|                     
 | |
|                     guard let latestProfile: Profile = Storage.shared.read({ db in 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 UIImage(contentsOfFile: filePath) != nil else {
 | |
|                         OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.")
 | |
|                         return
 | |
|                     }
 | |
|                     
 | |
|                     // Store the updated 'profilePictureFileName'
 | |
|                     Storage.shared.write { db in
 | |
|                         _ = try? Profile
 | |
|                             .filter(id: profile.id)
 | |
|                             .updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName))
 | |
|                         profileAvatarCache.mutate { $0[fileName] = decryptedData }
 | |
|                     }
 | |
|                     
 | |
|                     // Redundant but without reading 'backgroundTask' it will warn that the variable
 | |
|                     // isn't used
 | |
|                     if backgroundTask != nil { backgroundTask = nil }
 | |
|                 }
 | |
|                 .catch(on: queue) { _ in
 | |
|                     currentAvatarDownloads.mutate { $0.remove(profile.id) }
 | |
|                     
 | |
|                     // 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(
 | |
|         queue: DispatchQueue,
 | |
|         profileName: String,
 | |
|         image: UIImage?,
 | |
|         imageFilePath: String?,
 | |
|         success: ((Database, Profile) throws -> ())? = nil,
 | |
|         failure: ((ProfileManagerError) -> ())? = nil
 | |
|     ) {
 | |
|         queue.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()
 | |
|             let maxAvatarBytes: UInt = (5 * 1000 * 1000)
 | |
|             let avatarImageData: Data?
 | |
|             
 | |
|             do {
 | |
|                 avatarImageData = try {
 | |
|                     guard var image: UIImage = image else {
 | |
|                         guard let imageFilePath: String = imageFilePath else { return nil }
 | |
|                         
 | |
|                         let data: Data = try Data(contentsOf: URL(fileURLWithPath: imageFilePath))
 | |
|                         
 | |
|                         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)
 | |
|                             SNLog("Animated profile avatar was too large.")
 | |
|                             SNLog("Updating service with profile failed.")
 | |
|                             throw ProfileManagerError.avatarUploadMaxFileSizeExceeded
 | |
|                         }
 | |
|                         
 | |
|                         return data
 | |
|                     }
 | |
|                     
 | |
|                     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 {
 | |
|                         SNLog("Updating service with profile failed.")
 | |
|                         throw ProfileManagerError.avatarWriteFailed
 | |
|                     }
 | |
|                     
 | |
|                     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)
 | |
|                         SNLog("Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)")
 | |
|                         SNLog("Updating service with profile failed.")
 | |
|                         throw ProfileManagerError.avatarUploadMaxFileSizeExceeded
 | |
|                     }
 | |
|                     
 | |
|                     return data
 | |
|                 }()
 | |
|             }
 | |
|             catch {
 | |
|                 if let profileManagerError: ProfileManagerError = error as? ProfileManagerError {
 | |
|                     failure?(profileManagerError)
 | |
|                 }
 | |
|                 return
 | |
|             } 
 | |
|             
 | |
|             guard let data: Data = avatarImageData else {
 | |
|                 // If we have no image then we need to make sure to remove it from the profile
 | |
|                 Storage.shared.writeAsync { 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."
 | |
|                     )
 | |
|                     
 | |
|                     let updatedProfile: Profile = try existingProfile
 | |
|                         .with(
 | |
|                             name: profileName,
 | |
|                             profilePictureUrl: nil,
 | |
|                             profilePictureFileName: nil,
 | |
|                             profileEncryptionKey: (existingProfile.profilePictureUrl != nil ?
 | |
|                                 .update(newProfileKey) :
 | |
|                                 .existing
 | |
|                             )
 | |
|                         )
 | |
|                         .saved(db)
 | |
|                     
 | |
|                     // Remove any cached avatar image value
 | |
|                     if let fileName: String = existingProfile.profilePictureFileName {
 | |
|                         profileAvatarCache.mutate { $0[fileName] = nil }
 | |
|                     }
 | |
|                     
 | |
|                     SNLog("Successfully updated service with profile.")
 | |
|                     
 | |
|                     try success?(db, updatedProfile)
 | |
|                 }
 | |
|                 return
 | |
|             }
 | |
| 
 | |
|             // If we have a new avatar image, we must first:
 | |
|             //
 | |
|             // * 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 fileName: String = UUID().uuidString
 | |
|                 .appendingFileExtension(
 | |
|                     imageFilePath
 | |
|                         .map { URL(fileURLWithPath: $0).pathExtension }
 | |
|                         .defaulting(to: "jpg")
 | |
|                 )
 | |
|             let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
 | |
|             
 | |
|             // Write the avatar to disk
 | |
|             do { try data.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) }
 | |
|             catch {
 | |
|                 SNLog("Updating service with profile failed.")
 | |
|                 failure?(.avatarWriteFailed)
 | |
|                 return
 | |
|             }
 | |
|             
 | |
|             // Encrypt the avatar for upload
 | |
|             guard let encryptedAvatarData: Data = encryptProfileData(data: data, key: newProfileKey) else {
 | |
|                 SNLog("Updating service with profile failed.")
 | |
|                 failure?(.avatarEncryptionFailed)
 | |
|                 return
 | |
|             }
 | |
|             
 | |
|             // Upload the avatar to the FileServer
 | |
|             FileServerAPI
 | |
|                 .upload(encryptedAvatarData)
 | |
|                 .done(on: queue) { fileUploadResponse in
 | |
|                     let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileUploadResponse.id)"
 | |
|                     UserDefaults.standard[.lastProfilePictureUpload] = Date()
 | |
|                     
 | |
|                     Storage.shared.writeAsync { db in
 | |
|                         let profile: Profile = try Profile
 | |
|                             .fetchOrCreateCurrentUser(db)
 | |
|                             .with(
 | |
|                                 name: profileName,
 | |
|                                 profilePictureUrl: .update(downloadUrl),
 | |
|                                 profilePictureFileName: .update(fileName),
 | |
|                                 profileEncryptionKey: .update(newProfileKey)
 | |
|                             )
 | |
|                             .saved(db)
 | |
|                         
 | |
|                         // Update the cached avatar image value
 | |
|                         profileAvatarCache.mutate { $0[fileName] = data }
 | |
|                         
 | |
|                         SNLog("Successfully updated service with profile.")
 | |
|                         try success?(db, profile)
 | |
|                     }
 | |
|                 }
 | |
|                 .recover(on: queue) { error in
 | |
|                     SNLog("Updating service with profile failed.")
 | |
|                     
 | |
|                     let isMaxFileSizeExceeded: Bool = ((error as? HTTP.Error) == HTTP.Error.maxFileSizeExceeded)
 | |
|                     failure?(isMaxFileSizeExceeded ?
 | |
|                         .avatarUploadMaxFileSizeExceeded :
 | |
|                         .avatarUploadFailed
 | |
|                     )
 | |
|                 }
 | |
|                 .retainUntilComplete()
 | |
|         }
 | |
|     }
 | |
| }
 |