mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Updated the code to only auto-start attachment downloads when a user opens a conversation (and only for the current page of messages) Updated the GarbageCollectionJob to default to handling all cases (instead of requiring the cases to be defined) - this means we can add future cases without having to recreate the default job Added logic to remove approved blinded contact records as part of the GarbageCollectionJob Added code to better handle "invalid" attachments when migrating Added a mechanism to retrieve the details for currently running jobs (ie. allows us to check for duplicate concurrent jobs) Resolved the remaining TODOs in the GRDB migration code Cleaned up DB update logic to update only the targeted columns Fixed a bug due to a typo in a localised string Fixed a bug where link previews without images or with custom copy weren't being processed as link previews Fixed a bug where Open Groups could display with an empty name value
343 lines
15 KiB
Swift
343 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 {
|
|
// 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(_ db: Database? = nil, id: String) -> UIImage? {
|
|
guard let db: Database = db else {
|
|
return GRDBStorage.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) -> UIImage? {
|
|
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 = 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 = {
|
|
URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath())
|
|
.appendingPathComponent("ProfileAvatars")
|
|
.path
|
|
}()
|
|
|
|
private static let profileAvatarsDirPath: String = {
|
|
let path: String = ProfileManager.sharedDataProfileAvatarsDirPath
|
|
OWSFileSystem.ensureDirectoryExists(path)
|
|
|
|
return path
|
|
}()
|
|
|
|
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,
|
|
let profileUrlAtStart: URL = URL(string: profileUrlStringAtStart)
|
|
else {
|
|
SNLog("Skipping downloading avatar for \(profile.id) because url is not set")
|
|
return
|
|
}
|
|
guard
|
|
let fileId: Int64 = Int64(profileUrlAtStart.lastPathComponent),
|
|
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) }
|
|
|
|
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? Profile
|
|
.filter(id: profile.id)
|
|
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName))
|
|
profileAvatarCache.mutate { $0[fileName] = image }
|
|
}
|
|
|
|
// 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,
|
|
avatarImage: UIImage?,
|
|
requiredSync: Bool,
|
|
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()
|
|
|
|
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 { 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:
|
|
//
|
|
// * 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 {
|
|
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)
|
|
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 = 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) { fileId in
|
|
let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileId)"
|
|
UserDefaults.standard[.lastProfilePictureUpload] = Date()
|
|
|
|
GRDBStorage.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] = avatarImage }
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|