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.
301 lines
14 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|