mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Fixed an issue where fileIds weren't correctly getting sent along with open group messages Fixed an issue where the screens could miss updates if the device was locked with the app in the foreground and then later unlocked after receiving notifications Added an optimisation to prevent attempting to send a message after it has been deleted Added logic to report fake calls if the code goes down an invalid code path when handling a call (to prevent Apple blocking the app) Delayed the core which clears notifications to increase the time the app has to handle interactions (just in case it was a race condition)
404 lines
18 KiB
Swift
404 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 = 26
|
|
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?,
|
|
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()
|
|
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()
|
|
}
|
|
}
|
|
}
|