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.
session-ios/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.s...

301 lines
14 KiB
Swift

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable
import Foundation
import Combine
import GRDB
import SessionUtilitiesKit
import SessionSnodeKit
public enum DisplayPictureDownloadJob: JobExecutor {
public static var maxFailureCount: Int = 1
public static var requiresThreadId: Bool = false
public static var requiresInteractionId: Bool = false
public static func run(
_ job: Job,
queue: DispatchQueue,
success: @escaping (Job, Bool, Dependencies) -> (),
failure: @escaping (Job, Error?, Bool, Dependencies) -> (),
deferred: @escaping (Job, Dependencies) -> (),
using dependencies: Dependencies
) {
guard
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData),
let preparedDownload: HTTP.PreparedRequest<Data> = try? {
switch details.target {
case .profile(_, let url, _), .group(_, let url, _):
guard let fileId: String = Attachment.fileId(for: url) else { return nil }
return try FileServerAPI.preparedDownload(
fileId: fileId,
useOldServer: url.contains(FileServerAPI.oldServer),
using: dependencies
)
case .community(let fileId, let roomToken, let server):
return dependencies[singleton: .storage].read(using: dependencies) { db in
try OpenGroupAPI.preparedDownloadFile(
db,
fileId: fileId,
from: roomToken,
on: server,
using: dependencies
)
}
}
}()
else {
SNLog("[DisplayPictureDownloadJob] Failing due to missing details")
failure(job, JobRunnerError.missingRequiredDetails, true, dependencies)
return
}
let fileName: String = DisplayPictureManager.generateFilename(using: dependencies)
let filePath: String = DisplayPictureManager.filepath(for: fileName, using: dependencies)
preparedDownload
.send(using: dependencies)
.subscribe(on: DispatchQueue.global(qos: .background), using: dependencies)
.receive(on: DispatchQueue.global(qos: .background), using: dependencies)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .finished: success(job, false, dependencies)
case .failure(let error): failure(job, error, true, dependencies)
}
},
receiveValue: { _, data in
// Check to make sure this download is still a valid update
guard dependencies[singleton: .storage].read({ db in details.isValidUpdate(db) }) == true else {
return
}
guard
let decryptedData: Data = {
switch details.target {
case .community: return data // Community data is unencrypted
case .profile(_, _, let encryptionKey), .group(_, _, let encryptionKey):
return dependencies[singleton: .crypto].generate(
.decryptedDataDisplayPicture(data: data, key: encryptionKey, using: dependencies)
)
}
}()
else {
SNLog("[DisplayPictureDownloadJob] Failed to decrypt display picture for \(details.target)")
failure(job, DisplayPictureError.writeFailed, true, dependencies)
return
}
// Ensure the data is actually image data and then save it to disk
guard
UIImage(data: decryptedData) != nil,
dependencies[singleton: .fileManager].createFile(
atPath: filePath,
contents: decryptedData
)
else {
SNLog("[DisplayPictureDownloadJob] Failed to load display picture for \(details.target)")
failure(job, DisplayPictureError.writeFailed, true, dependencies)
return
}
// Update the cache first (in case the DBWrite thread is blocked, this way other threads
// can retrieve from the cache and avoid triggering a download)
dependencies.mutate(cache: .displayPicture) { cache in
cache.imageData[fileName] = decryptedData
}
// Store the updated information in the database
dependencies[singleton: .storage].write { db in
switch details.target {
case .profile(let id, let url, let encryptionKey):
_ = try? Profile
.filter(id: id)
.updateAllAndConfig(
db,
Profile.Columns.profilePictureUrl.set(to: url),
Profile.Columns.profileEncryptionKey.set(to: encryptionKey),
Profile.Columns.profilePictureFileName.set(to: fileName),
Profile.Columns.lastProfilePictureUpdate.set(to: details.timestamp)
)
case .group(let id, let url, let encryptionKey):
_ = try? ClosedGroup
.filter(id: id)
.updateAllAndConfig(
db,
ClosedGroup.Columns.displayPictureUrl.set(to: url),
ClosedGroup.Columns.displayPictureEncryptionKey.set(to: encryptionKey),
ClosedGroup.Columns.displayPictureFilename.set(to: fileName),
ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: details.timestamp)
)
case .community(_, let roomToken, let server):
_ = try? OpenGroup
.filter(id: OpenGroup.idFor(roomToken: roomToken, server: server))
.updateAllAndConfig(
db,
OpenGroup.Columns.displayPictureFilename.set(to: fileName),
OpenGroup.Columns.lastDisplayPictureUpdate.set(to: details.timestamp)
)
}
}
}
)
}
}
// MARK: - DisplayPictureDownloadJob.Details
extension DisplayPictureDownloadJob {
public enum Target: Codable, Hashable, CustomStringConvertible {
case profile(id: String, url: String, encryptionKey: Data)
case group(id: String, url: String, encryptionKey: Data)
case community(imageId: String, roomToken: String, server: String)
var isValid: Bool {
switch self {
case .profile(_, let url, let encryptionKey), .group(_, let url, let encryptionKey):
return (
!url.isEmpty &&
Attachment.fileId(for: url) != nil &&
encryptionKey.count == DisplayPictureManager.aes256KeyByteLength
)
case .community(let imageId, _, _): return !imageId.isEmpty
}
}
// MARK: - CustomStringConvertible
public var description: String {
switch self {
case .profile(let id, _, _): return "profile: \(id)"
case .group(let id, _, _): return "group: \(id)"
case .community(_, let roomToken, let server): return "room: \(roomToken) on server: \(server)"
}
}
}
public struct Details: Codable, Hashable {
public let target: Target
public let timestamp: TimeInterval
// MARK: - Hashable
public func hash(into hasher: inout Hasher) {
/// We intentionally leave `timestamp` out of the hash value because when we insert the job we want
/// it to prevent duplicate jobs from being added with the same `target` information and including
/// the `timestamp` could likely result in multiple jobs downloading the same `target`
target.hash(into: &hasher)
}
// MARK: - Initialization
public init?(target: Target, timestamp: TimeInterval) {
guard target.isValid else { return nil }
self.target = {
switch target {
case .community(let imageId, let roomToken, let server):
return .community(
imageId: imageId,
roomToken: roomToken,
server: server.lowercased() // Always in lowercase on `OpenGroup`
)
default: return target
}
}()
self.timestamp = timestamp
}
public init?(owner: DisplayPictureManager.Owner) {
switch owner {
case .user(let profile):
guard
let url: String = profile.profilePictureUrl,
let key: Data = profile.profileEncryptionKey,
let details: Details = Details(
target: .profile(id: profile.id, url: url, encryptionKey: key),
timestamp: (profile.lastProfilePictureUpdate ?? 0)
)
else { return nil }
self = details
case .group(let group):
guard
let url: String = group.displayPictureUrl,
let key: Data = group.displayPictureEncryptionKey,
let details: Details = Details(
target: .group(id: group.id, url: url, encryptionKey: key),
timestamp: (group.lastDisplayPictureUpdate ?? 0)
)
else { return nil }
self = details
case .community(let openGroup):
guard
let imageId: String = openGroup.imageId,
let details: Details = Details(
target: .community(
imageId: imageId,
roomToken: openGroup.roomToken,
server: openGroup.server
),
timestamp: (openGroup.lastDisplayPictureUpdate ?? 0)
)
else { return nil }
self = details
case .file: return nil
}
}
// MARK: - Functions
fileprivate func isValidUpdate(_ db: Database) -> Bool {
switch self.target {
case .profile(let id, let url, let encryptionKey):
guard let latestProfile: Profile = try? Profile.fetchOne(db, id: id) else { return false }
return (
timestamp >= (latestProfile.lastProfilePictureUpdate ?? 0) || (
encryptionKey == latestProfile.profileEncryptionKey &&
url == latestProfile.profilePictureUrl
)
)
case .group(let id, let url, let encryptionKey):
guard let latestGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: id) else { return false }
return (
timestamp >= (latestGroup.lastDisplayPictureUpdate ?? 0) || (
encryptionKey == latestGroup.displayPictureEncryptionKey &&
url == latestGroup.displayPictureUrl
)
)
case .community(let imageId, let roomToken, let server):
guard
let latestGroup: OpenGroup = try? OpenGroup.fetchOne(
db,
id: OpenGroup.idFor(roomToken: roomToken, server: server)
)
else { return false }
return (
timestamp >= (latestGroup.lastDisplayPictureUpdate ?? 0) ||
imageId == latestGroup.imageId
)
}
}
}
}