diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 8829f6283..75c7c4a34 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -514,6 +514,8 @@ FD19363F2ACA66DE004BCF0F /* DatabaseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */; }; FD1936422ACBE084004BCF0F /* MockSessionUtilCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB947162A9D69A8001F271A /* MockSessionUtilCache.swift */; }; FD1936432ACBE11F004BCF0F /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB9471A2A9D70A6001F271A /* CommonSMKMockExtensions.swift */; }; + FD1936452ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1936442ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift */; }; + FD1936472ACE752F004BCF0F /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1936462ACE752F004BCF0F /* _005_AddJobUniqueHash.swift */; }; FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */; }; FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FD2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; @@ -1741,6 +1743,8 @@ FD1936392ACA25BA004BCF0F /* UpdatableTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatableTimestamp.swift; sourceTree = ""; }; FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResponseInfo+SnodeAPI.swift"; sourceTree = ""; }; FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSpec.swift; sourceTree = ""; }; + FD1936442ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayPictureDownloadJob.swift; sourceTree = ""; }; + FD1936462ACE752F004BCF0F /* _005_AddJobUniqueHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_AddJobUniqueHash.swift; sourceTree = ""; }; FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = ""; }; FD1A94FD2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistableRecordUtilitiesSpec.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; @@ -3879,6 +3883,7 @@ FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */, FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */, FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */, + FD1936462ACE752F004BCF0F /* _005_AddJobUniqueHash.swift */, ); path = Migrations; sourceTree = ""; @@ -4593,6 +4598,7 @@ 7B7E5B512A4D024C00A8208E /* ExpirationUpdateJob.swift */, 7B7AD41E2A5512CA00469FB1 /* GetExpirationJob.swift */, FDB5DAD02A94838C002C8721 /* GroupInviteMemberJob.swift */, + FD1936442ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift */, ); path = Types; sourceTree = ""; @@ -6117,6 +6123,7 @@ C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */, FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */, FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */, + FD1936472ACE752F004BCF0F /* _005_AddJobUniqueHash.swift in Sources */, FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */, FD9AECA72AAAF5B0009B3406 /* Crypto+SessionUtilitiesKit.swift in Sources */, C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, @@ -6239,6 +6246,7 @@ FD245C58285065F700B966DD /* OpenGroupServerIdLookup.swift in Sources */, FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */, FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */, + FD1936452ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift in Sources */, FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */, FDD82C3F2A205D0A00425F05 /* ProcessResult.swift in Sources */, FD716E6A2850327900C96BF4 /* EndCallMode.swift in Sources */, diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index bf3e0b67f..e6a90f8e9 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -60,6 +60,7 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API .configurationSync: ConfigurationSyncJob.self, .configMessageReceive: ConfigMessageReceiveJob.self, .expirationUpdate: ExpirationUpdateJob.self, + .displayPictureDownload: DisplayPictureDownloadJob.self, .getExpiration: GetExpirationJob.self ] diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index 84e1c46d6..97e4fe9a2 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -1,4 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation import Combine @@ -6,7 +8,6 @@ import SessionSnodeKit import SessionUtilitiesKit public enum FileServerAPI { - // MARK: - Settings public static let oldServer = "http://88.99.175.227" @@ -24,26 +25,6 @@ public enum FileServerAPI { // MARK: - File Storage - public static func upload( - _ file: Data, - using dependencies: Dependencies = Dependencies() - ) -> AnyPublisher { - let request = Request( - method: .post, - server: server, - endpoint: Endpoint.file, - headers: [ - .contentDisposition: "attachment", - .contentType: "application/octet-stream" - ], - x25519PublicKey: serverPublicKey, - body: Array(file) - ) - - return send(request, serverPublicKey: serverPublicKey, timeout: FileServerAPI.fileUploadTimeout, using: dependencies) - .decoded(as: FileUploadResponse.self) - } - public static func preparedUpload( _ file: Data, using dependencies: Dependencies = Dependencies() @@ -66,72 +47,45 @@ public enum FileServerAPI { ) } - public static func download( - _ fileId: String, + public static func preparedDownload( + fileId: String, useOldServer: Bool, using dependencies: Dependencies = Dependencies() - ) -> AnyPublisher { - let serverPublicKey: String = (useOldServer ? oldServerPublicKey : serverPublicKey) - let request = Request( - server: (useOldServer ? oldServer : server), - endpoint: .fileIndividual(fileId: fileId), - x25519PublicKey: serverPublicKey + ) throws -> HTTP.PreparedRequest { + return try prepareRequest( + request: Request( + server: (useOldServer ? oldServer : server), + endpoint: .fileIndividual(fileId: fileId), + x25519PublicKey: (useOldServer ? oldServerPublicKey : serverPublicKey) + ), + responseType: Data.self, + timeout: FileServerAPI.fileDownloadTimeout, + using: dependencies ) - - return send(request, serverPublicKey: serverPublicKey, timeout: FileServerAPI.fileDownloadTimeout, using: dependencies) } - public static func getVersion( + public static func preparedGetVersion( _ platform: String, using dependencies: Dependencies = Dependencies() - ) -> AnyPublisher { - let request = Request( - server: server, - endpoint: .sessionVersion, - queryParameters: [ - .platform: platform - ], - x25519PublicKey: serverPublicKey + ) throws -> HTTP.PreparedRequest { + return try prepareRequest( + request: Request( + server: server, + endpoint: Endpoint.sessionVersion, + queryParameters: [ + .platform: platform + ], + x25519PublicKey: serverPublicKey + ), + responseType: VersionResponse.self, + timeout: HTTP.defaultTimeout, + using: dependencies ) - - return send(request, serverPublicKey: serverPublicKey, timeout: HTTP.defaultTimeout, using: dependencies) - .decoded(as: VersionResponse.self) - .map { response in response.version } - .eraseToAnyPublisher() + .map { _, response in response.version } } // MARK: - Convenience - private static func send( - _ request: Request, - serverPublicKey: String, - timeout: TimeInterval, - using dependencies: Dependencies - ) -> AnyPublisher { - let preparedRequest: HTTP.PreparedRequest - - do { - preparedRequest = try prepareRequest( - request: request, - responseType: Data?.self, - timeout: timeout, - using: dependencies - ) - } - catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - - return preparedRequest.send(using: dependencies) - .tryMap { _, response -> Data in - guard let response: Data = response else { throw HTTPError.parsingFailed } - - return response - } - .eraseToAnyPublisher() - } - private static func prepareRequest( request: Request, responseType: R.Type, diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 71f7c3ef3..a78c2846f 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -95,32 +95,28 @@ public enum AttachmentDownloadJob: JobExecutor { else { throw AttachmentDownloadError.invalidUrl } return dependencies[singleton: .storage] - .readPublisher { db -> HTTP.PreparedRequest? in - try OpenGroup.fetchOne(db, id: threadId) - .map { openGroup in - try OpenGroupAPI + .readPublisher { db -> HTTP.PreparedRequest in + switch try OpenGroup.fetchOne(db, id: threadId) { + case .some(let openGroup): + return try OpenGroupAPI .preparedDownloadFile( db, fileId: fileId, from: openGroup.roomToken, on: openGroup.server ) - } - } - .flatMap { maybePreparedRequest -> AnyPublisher in - guard let preparedRequest: HTTP.PreparedRequest = maybePreparedRequest else { - return FileServerAPI - .download( - fileId, - useOldServer: downloadUrl.contains(FileServerAPI.oldServer) - ) - .eraseToAnyPublisher() + + case .none: + return try FileServerAPI + .preparedDownload( + fileId: fileId, + useOldServer: downloadUrl.contains(FileServerAPI.oldServer), + using: dependencies + ) } - - return preparedRequest.send(using: dependencies) - .map { _, data in data } - .eraseToAnyPublisher() } + .flatMap { $0.send(using: dependencies) } + .map { _, data in data } .eraseToAnyPublisher() } .subscribe(on: queue) diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index c4560135d..718814f4e 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -93,7 +93,7 @@ public enum AttachmentUploadJob: JobExecutor { MessageSender.handleFailedMessageSend( db, message: details.message, - with: .other("[AttachmentUploadJob] Failed", error), + with: MessageSenderError.other("[AttachmentUploadJob] Failed", error), interactionId: interactionId, isSyncMessage: details.isSyncMessage, using: dependencies diff --git a/SessionMessagingKit/Jobs/Types/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/Types/DisplayPictureDownloadJob.swift new file mode 100644 index 000000000..e50e08ff2 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/DisplayPictureDownloadJob.swift @@ -0,0 +1,155 @@ +// 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 = try? { + switch details.target { + case .profile(_, let url, let encryptionKey): + guard + !url.isEmpty, + let fileId: String = Attachment.fileId(for: url), + encryptionKey.count == ProfileManager.avatarAES256KeyByteLength + else { return nil } + + return try FileServerAPI.preparedDownload( + fileId: fileId, + useOldServer: url.contains(FileServerAPI.oldServer), + using: dependencies + ) + } + }() + else { + SNLog("[DisplayPictureDownloadJob] Failing due to missing details") + failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) + return + } + + let fileName: String = UUID().uuidString.appendingFileExtension("jpg") + let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName) + + 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 + switch details.target { + case .profile(let id, let url, let encryptionKey): + guard let latestProfile: Profile = dependencies[singleton: .storage].read({ db in try Profile.fetchOne(db, id: id) }) else { + return + } + + // Check to make sure this download matches the profile settings + guard + details.timestamp >= (latestProfile.lastProfilePictureUpdate ?? 0) || ( + encryptionKey == latestProfile.profileEncryptionKey && + url == latestProfile.profilePictureUrl + ) + else { return } + + guard let decryptedData: Data = ProfileManager.decryptData(data: data, key: encryptionKey) else { + SNLog("[DisplayPictureDownloadJob] Failed to decrypt display picture for \(id)") + failure(job, ProfileManagerError.avatarWriteFailed, true, dependencies) + return + } + + try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) + + guard UIImage(contentsOfFile: filePath) != nil else { + SNLog("[DisplayPictureDownloadJob] Failed to load display picture for \(id)") + failure(job, ProfileManagerError.avatarWriteFailed, 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) + ProfileManager.cache(fileName: fileName, avatarData: decryptedData) + + // Store the updated 'profilePictureFileName' + dependencies[singleton: .storage].write { db in + _ = 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) + ) + } + } + } + ) + } +} + +// MARK: - DisplayPictureDownloadJob.Details + +extension DisplayPictureDownloadJob { + public enum Target: Codable, Hashable { + case profile(id: String, url: String, encryptionKey: Data) + } + + public struct Details: Codable, Hashable { + public let target: Target + public let timestamp: TimeInterval + + public init?(target: Target, timestamp: TimeInterval) { + switch target { + case .profile(_, let url, let encryptionKey): + guard + !url.isEmpty, + Attachment.fileId(for: url) != nil, + encryptionKey.count == ProfileManager.avatarAES256KeyByteLength + else { return nil } + + break + } + + self.target = target + self.timestamp = timestamp + } + + public init?(profile: 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 + } + } +} + diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index b02d31b24..70c186385 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -48,7 +48,7 @@ public final class VisibleMessage: Message { // default logic, otherwise we want to check guard !isValid || attachmentIds.isEmpty else { return isValid } - return (dataMessageHasAttachments == false) + return (dataMessageHasAttachments == true) } // MARK: - Initialization diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index b113b710b..65918301d 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -141,7 +141,7 @@ extension MessageSender { dependencies[singleton: .jobRunner].add( db, job: Job( - variant: .groupInviteMemberJob, + variant: .groupInviteMember, threadId: thread.id, details: GroupInviteMemberJob.Details( memberSubkey: Data(), diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift index a81383697..589d93f23 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -331,16 +331,13 @@ internal extension SessionUtil { contact.profile_pic.url = updatedProfile.profilePictureUrl.toLibSession() contact.profile_pic.key = updatedProfile.profileEncryptionKey.toLibSession() - // Download the profile picture if needed (this can be triggered within - // database reads/writes so dispatch the download to a separate queue to - // prevent blocking) + // Attempts retrieval of the profile picture (will schedule a download if + // needed via a throttled subscription on another thread to prevent blocking) if oldAvatarUrl != (updatedProfile.profilePictureUrl ?? "") || oldAvatarKey != (updatedProfile.profileEncryptionKey ?? Data(repeating: 0, count: ProfileManager.avatarAES256KeyByteLength)) { - DispatchQueue.global(qos: .background).async { - ProfileManager.downloadAvatar(for: updatedProfile) - } + ProfileManager.profileAvatar(profile: updatedProfile) } // Store the updated contact (needs to happen before variables go out of scope) diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 0822277c1..24487feb2 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -5,6 +5,7 @@ import CryptoKit import Combine import GRDB import SignalCoreKit +import SessionSnodeKit import SessionUtilitiesKit public struct ProfileManager { @@ -15,6 +16,11 @@ public struct ProfileManager { case updateTo(url: String, key: Data, fileName: String?) } + private struct DownloadInfo: Hashable { + let profile: Profile + let currentFileInvalid: Bool + } + // 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. public static let maxAvatarDiameter: CGFloat = 640 @@ -26,6 +32,10 @@ public struct ProfileManager { private static var profileAvatarCache: Atomic<[String: Data]> = Atomic([:]) private static var currentAvatarDownloads: Atomic> = Atomic([]) + private static var downloadsToSchedule: Atomic> = Atomic([]) + private static var scheduleDownloadsPublisher: AnyPublisher? + private static let scheduleDownloadsTrigger: PassthroughSubject<(), Never> = PassthroughSubject() + // MARK: - Functions public static func isToLong(profileName: String) -> Bool { @@ -49,7 +59,7 @@ public struct ProfileManager { return profileAvatar(profile: profile) } - public static func profileAvatar( + @discardableResult public static func profileAvatar( profile: Profile, using dependencies: Dependencies = Dependencies() ) -> Data? { @@ -58,10 +68,7 @@ public struct ProfileManager { } if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty { - // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this - dependencies[singleton: .jobRunner].afterBlockingQueue { - ProfileManager.downloadAvatar(for: profile) - } + scheduleDownload(for: profile, currentFileInvalid: false, using: dependencies) } return nil @@ -81,24 +88,8 @@ public struct ProfileManager { 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 - dependencies[singleton: .storage].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 { - // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this - dependencies[singleton: .jobRunner].afterBlockingQueue { - ProfileManager.downloadAvatar(for: profile) - } - } - } - ) + // If we can't load the avatar or it's an invalid/corrupted image then clear it out and re-download + scheduleDownload(for: profile, currentFileInvalid: true, using: dependencies) return nil } @@ -119,9 +110,66 @@ public struct ProfileManager { return try? Data(contentsOf: URL(fileURLWithPath: filePath)) } + public static func cache(fileName: String, avatarData: Data) { + profileAvatarCache.mutate { $0[fileName] = avatarData } + } + + private static func scheduleDownload( + for profile: Profile, + currentFileInvalid invalid: Bool, + using dependencies: Dependencies + ) { + downloadsToSchedule.mutate { $0 = $0.inserting(DownloadInfo(profile: profile, currentFileInvalid: invalid)) } + + /// This method can be triggered very frequently when processing messages so we want to throttle the updates to 250ms (it's for starting + /// avatar downloads so that should definitely be fast enough) + if scheduleDownloadsPublisher == nil { + scheduleDownloadsPublisher = scheduleDownloadsTrigger + .throttle(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true) + .handleEvents( + receiveOutput: { _ in + let pendingInfo: Set = downloadsToSchedule.mutate { + let result: Set = $0 + $0.removeAll() + return result + } + + dependencies[singleton: .storage].writeAsync(using: dependencies) { db in + pendingInfo.forEach { info in + // If the current file is invalid then clear out the 'profilePictureFileName' + // and try to re-download the file + if info.currentFileInvalid { + _ = try? Profile + .filter(id: profile.id) + .updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil)) + } + + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details(profile: info.profile) + ), + canStartJob: true, + using: dependencies + ) + } + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + + scheduleDownloadsPublisher?.sinkUntilComplete() + } + + scheduleDownloadsTrigger.send(()) + } + // MARK: - Profile Encryption - private static func encryptData(data: Data, key: Data) -> Data? { + internal static func encryptData(data: Data, key: Data) -> Data? { // The key structure is: nonce || ciphertext || authTag guard key.count == ProfileManager.avatarAES256KeyByteLength, @@ -138,7 +186,7 @@ public struct ProfileManager { return encryptedContent } - private static func decryptData(data: Data, key: Data) -> Data? { + internal static func decryptData(data: Data, key: Data) -> Data? { guard key.count == ProfileManager.avatarAES256KeyByteLength else { return nil } // The key structure is: nonce || ciphertext || authTag @@ -205,99 +253,6 @@ public struct ProfileManager { try? FileManager.default.removeItem(atPath: ProfileManager.profileAvatarsDirPath) } - // MARK: - Other Users' Profiles - - public static func downloadAvatar( - for profile: Profile, - funcName: String = #function, - using dependencies: Dependencies = Dependencies() - ) { - 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: Data = profile.profileEncryptionKey, - profileKeyAtStart.count > 0 - else { - return - } - - let fileName: String = UUID().uuidString.appendingFileExtension("jpg") - let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName) - var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: funcName) - - 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) - .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) - .receive(on: DispatchQueue.global(qos: .background), using: dependencies) - .sinkUntilComplete( - receiveCompletion: { _ 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 } - }, - receiveValue: { data in - guard let latestProfile: Profile = dependencies[singleton: .storage].read({ db in try Profile.fetchOne(db, id: profile.id) }) else { - return - } - - guard - let latestProfileKey: Data = latestProfile.profileEncryptionKey, - !latestProfileKey.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 = decryptData(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 - } - - // 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) - profileAvatarCache.mutate { $0[fileName] = decryptedData } - - // Store the updated 'profilePictureFileName' - dependencies[singleton: .storage].write { db in - _ = try? Profile - .filter(id: profile.id) - .updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName)) - } - } - ) - } - // MARK: - Current User Profile public static func updateLocal( @@ -483,8 +438,14 @@ public struct ProfileManager { } // Upload the avatar to the FileServer - FileServerAPI - .upload(encryptedAvatarData) + guard let preparedUpload: HTTP.PreparedRequest = try? FileServerAPI.preparedUpload(encryptedAvatarData, using: dependencies) else { + SNLog("Updating service with profile failed.") + failure?(.avatarUploadFailed) + return + } + + preparedUpload + .send(using: dependencies) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: queue) .sinkUntilComplete( @@ -501,7 +462,7 @@ public struct ProfileManager { ) } }, - receiveValue: { fileUploadResponse in + receiveValue: { _, fileUploadResponse in let downloadUrl: String = "\(FileServerAPI.server)/file/\(fileUploadResponse.id)" // Update the cached avatar image value @@ -544,9 +505,6 @@ public struct ProfileManager { } // Profile picture & profile key - var avatarNeedsDownload: Bool = false - var targetAvatarUrl: String? = nil - if sentTimestamp > (profile.lastProfilePictureUpdate ?? 0) || (isCurrentUser && calledFromConfigHandling) { switch avatarUpdate { case .none: break @@ -558,70 +516,48 @@ public struct ProfileManager { profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil)) profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp)) - case .updateTo(let url, let key, let fileName): + case .updateTo(let url, let key, .some(let fileName)) where ProfileManager.hasProfileImageData(with: fileName): + // Update the 'lastProfilePictureUpdate' timestamp for either external or local changes + profileChanges.append(Profile.Columns.profilePictureFileName.set(to: fileName)) + profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp)) + if url != profile.profilePictureUrl { profileChanges.append(Profile.Columns.profilePictureUrl.set(to: url)) - avatarNeedsDownload = true - targetAvatarUrl = url } if key != profile.profileEncryptionKey && key.count == ProfileManager.avatarAES256KeyByteLength { profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: key)) } - // Profile filename (this isn't synchronized between devices) - if let fileName: String = fileName { - profileChanges.append(Profile.Columns.profilePictureFileName.set(to: fileName)) - - // If we have already downloaded the image then no need to download it again - avatarNeedsDownload = ( - avatarNeedsDownload && - !ProfileManager.hasProfileImageData(with: fileName) - ) - } - - // Update the 'lastProfilePictureUpdate' timestamp for either external or local changes - profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp)) - } - } - - // Persist any changes - if !profileChanges.isEmpty { - try profile.save(db) - - if calledFromConfigHandling { - try Profile - .filter(id: publicKey) - .updateAll( // Handling a config update so don't use `updateAllAndConfig` + case .updateTo(let url, let key, _): + dependencies[singleton: .jobRunner].add( db, - profileChanges - ) - } - else { - try Profile - .filter(id: publicKey) - .updateAllAndConfig( - db, - profileChanges, - calledFromConfig: calledFromConfigHandling, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .profile(id: profile.id, url: url, encryptionKey: key), + timestamp: sentTimestamp + ) + ), + canStartJob: true, using: dependencies ) } } - // Download the profile picture if needed - guard avatarNeedsDownload else { return } - - let dedupeIdentifier: String = "AvatarDownload-\(publicKey)-\(targetAvatarUrl ?? "remove")" - - db.afterNextTransactionNestedOnce(dedupeId: dedupeIdentifier) { db in - // Need to refetch to ensure the db changes have occurred - let targetProfile: Profile = Profile.fetchOrCreate(db, id: publicKey) + // Persist any changes + if !profileChanges.isEmpty { + try profile.save(db) - // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this - dependencies[singleton: .jobRunner].afterBlockingQueue { - ProfileManager.downloadAvatar(for: targetProfile) - } + try Profile + .filter(id: publicKey) + .updateAllAndConfig( + db, + profileChanges, + calledFromConfig: calledFromConfigHandling, + using: dependencies + ) } } } diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index 3cad4b5e8..3647485cc 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -23,7 +23,8 @@ public enum SNUtilitiesKit: MigratableTarget { // Just to make the external API [], // Other DB migrations [], // Legacy DB removal [ - _004_AddJobPriority.self + _004_AddJobPriority.self, + _005_AddJobUniqueHash.self ] ] ) diff --git a/SessionUtilitiesKit/Database/Migrations/_005_AddJobUniqueHash.swift b/SessionUtilitiesKit/Database/Migrations/_005_AddJobUniqueHash.swift new file mode 100644 index 000000000..a4712d479 --- /dev/null +++ b/SessionUtilitiesKit/Database/Migrations/_005_AddJobUniqueHash.swift @@ -0,0 +1,22 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +enum _005_AddJobUniqueHash: Migration { + static let target: TargetMigrations.Identifier = .utilitiesKit + static let identifier: String = "AddJobUniqueHash" // stringlint:disable + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 + static let fetchedTables: [(TableRecord & FetchableRecord).Type] = [] + static let createdOrAlteredTables: [(TableRecord & FetchableRecord).Type] = [Job.self] + + static func migrate(_ db: Database, using dependencies: Dependencies) throws { + // Add `uniqueHashValue` to the job table + try db.alter(table: Job.self) { t in + t.add(.uniqueHashValue, .integer) + } + + Storage.update(progress: 1, for: self, in: target, using: dependencies) + } +} diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 78fe73c24..57ddea9f1 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -38,6 +38,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, case threadId case interactionId case details + case uniqueHashValue } public enum Variant: Int, Codable, DatabaseValueConvertible, CaseIterable { @@ -125,9 +126,14 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, /// needs to get expiration from network case getExpiration + /// This is a job which downloads a display picture for a user, group or community (it's separate from the + /// `attachmentDownloadJob` as these files are likely to be much smaller so we don't want them to be + /// blocked by larger attachment downloads + case displayPictureDownload + /// This is a job which sends an invitation to a member of a group asynchronously so the admin doesn't need to /// wait during group creation - case groupInviteMemberJob + case groupInviteMember } public enum Behaviour: Int, Codable, DatabaseValueConvertible, CaseIterable { @@ -201,6 +207,12 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, /// JSON encoded data required for the job public let details: Data? + /// When initalizing with `shouldBeUnique` set to `true` this value will be populated with a hash constructed by + /// combining the `variant`, `threadId`, `interactionId` and `details` and if this value is populated + /// adding/inserting a `Job` will fail if there is already a job with the same `uniqueHashValue` in the database or + /// in the `JobRunner` + public let uniqueHashValue: Int? + /// The other jobs which this job is dependant on /// /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is @@ -226,6 +238,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, variant: Variant, behaviour: Behaviour, shouldBlock: Bool, + shouldBeUnique: Bool, shouldSkipLaunchBecomeActive: Bool, nextRunTimestamp: TimeInterval, threadId: String?, @@ -249,6 +262,13 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, self.threadId = threadId self.interactionId = interactionId self.details = details + self.uniqueHashValue = Job.createUniqueHash( + shouldBeUnique: shouldBeUnique, + variant: variant, + threadId: threadId, + interactionId: interactionId, + detailsData: details + ) } public init( @@ -257,6 +277,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, variant: Variant, behaviour: Behaviour = .runOnce, shouldBlock: Bool = false, + shouldBeUnique: Bool = false, shouldSkipLaunchBecomeActive: Bool = false, nextRunTimestamp: TimeInterval = 0, threadId: String? = nil, @@ -278,6 +299,13 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, self.threadId = threadId self.interactionId = interactionId self.details = nil + self.uniqueHashValue = Job.createUniqueHash( + shouldBeUnique: shouldBeUnique, + variant: variant, + threadId: threadId, + interactionId: interactionId, + detailsData: nil + ) } public init?( @@ -286,6 +314,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, variant: Variant, behaviour: Behaviour = .runOnce, shouldBlock: Bool = false, + shouldBeUnique: Bool = false, shouldSkipLaunchBecomeActive: Bool = false, nextRunTimestamp: TimeInterval = 0, threadId: String? = nil, @@ -316,6 +345,13 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, self.threadId = threadId self.interactionId = interactionId self.details = detailsData + self.uniqueHashValue = Job.createUniqueHash( + shouldBeUnique: shouldBeUnique, + variant: variant, + threadId: threadId, + interactionId: interactionId, + detailsData: detailsData + ) } fileprivate static func ensureValidBehaviour( @@ -335,6 +371,26 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, ) } + private static func createUniqueHash( + shouldBeUnique: Bool, + variant: Variant, + threadId: String?, + interactionId: Int64?, + detailsData: Data? + ) -> Int? { + // Only generate a unique hash if the Job should actually be unique (we don't want to prevent + // all duplicate jobs, just the ones explicitly marked as unique) + guard shouldBeUnique else { return nil } + + var hasher: Hasher = Hasher() + variant.hash(into: &hasher) + threadId?.hash(into: &hasher) + interactionId?.hash(into: &hasher) + detailsData?.hash(into: &hasher) + + return hasher.finalize() + } + // MARK: - Custom Database Interaction public mutating func didInsert(_ inserted: InsertionSuccess) { @@ -400,6 +456,7 @@ public extension Job { variant: self.variant, behaviour: self.behaviour, shouldBlock: self.shouldBlock, + shouldBeUnique: (self.uniqueHashValue != nil), shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive, nextRunTimestamp: nextRunTimestamp, threadId: self.threadId, @@ -422,6 +479,7 @@ public extension Job { variant: self.variant, behaviour: self.behaviour, shouldBlock: self.shouldBlock, + shouldBeUnique: (self.uniqueHashValue != nil), shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive, nextRunTimestamp: self.nextRunTimestamp, threadId: self.threadId, diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 1aae4cca8..c805969a7 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -152,6 +152,7 @@ public final class JobRunner: JobRunnerType { public let threadId: String? public let interactionId: Int64? public let detailsData: Data? + public let uniqueHashValue: Int? public var debugDescription: String { let dataDescription: String = detailsData @@ -163,12 +164,18 @@ public final class JobRunner: JobRunnerType { "variant: \(variant),", " threadId: \(threadId ?? "nil"),", " interactionId: \(interactionId.map { "\($0)" } ?? "nil"),", - " detailsData: \(dataDescription)", + " detailsData: \(dataDescription),", + " uniqueHashValue: \(uniqueHashValue.map { "\($0)" } ?? "nil")", ")" ].joined() } } + private enum Validation { + case enqueueOnly + case persist + } + // MARK: - Variables private let allowToExecuteJobs: Bool @@ -276,6 +283,18 @@ public final class JobRunner: JobRunnerType { ].compactMap { $0 } ), + // MARK: -- Display Picture Download Queue + + JobQueue( + type: .displayPictureDownload, + executionType: .serial, + qos: .utility, + isTestingJobRunner: isTestingJobRunner, + jobVariants: [ + jobVariants.remove(.displayPictureDownload) + ].compactMap { $0 } + ), + // MARK: -- General Queue JobQueue( @@ -369,7 +388,8 @@ public final class JobRunner: JobRunnerType { variant: job.variant, threadId: job.threadId, interactionId: job.interactionId, - detailsData: job.details + detailsData: job.details, + uniqueHashValue: job.uniqueHashValue ) ) }) @@ -609,15 +629,7 @@ public final class JobRunner: JobRunnerType { canStartJob: Bool, using dependencies: Dependencies ) -> Job? { - // Store the job into the database (getting an id for it) - guard let updatedJob: Job = try? job?.inserted(db) else { - SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job") - return nil - } - guard !canStartJob || updatedJob.id != nil else { - SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") - return nil - } + guard let updatedJob: Job = validatedJob(db, job: job, validation: .persist) else { return nil } // Don't add to the queue if the JobRunner isn't ready (it's been saved to the db so it'll be loaded // once the queue actually get started later) @@ -647,20 +659,21 @@ public final class JobRunner: JobRunnerType { add(db, job: job, canStartJob: canStartJob, using: dependencies) return } + guard let updatedJob: Job = validatedJob(db, job: job, validation: .enqueueOnly) else { return } // Don't add to the queue if the JobRunner isn't ready (it's been saved to the db so it'll be loaded // once the queue actually get started later) - guard canAddToQueue(job) else { return } + guard canAddToQueue(updatedJob) else { return } - queues.wrappedValue[job.variant]?.upsert(db, job: job, canStartJob: canStartJob, using: dependencies) + queues.wrappedValue[updatedJob.variant]? + .upsert(db, job: updatedJob, canStartJob: canStartJob, using: dependencies) // Don't start the queue if the job can't be started guard canStartJob else { return } // Start the job runner if needed - - db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(job.variant)") { [weak self] _ in - self?.queues.wrappedValue[job.variant]?.start(using: dependencies) + db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(updatedJob.variant)") { [weak self] _ in + self?.queues.wrappedValue[updatedJob.variant]?.start(using: dependencies) } } @@ -671,21 +684,16 @@ public final class JobRunner: JobRunnerType { ) -> (Int64, Job)? { switch job?.behaviour { case .recurringOnActive, .recurringOnLaunch, .runOnceNextLaunch: - SNLog("[JobRunner] Attempted to insert \(job.map { "\($0.variant)" } ?? "unknown") job before the current one even though it's behaviour is \(job.map { "\($0.behaviour)" } ?? "unknown")") + SNLog("[JobRunner] Attempted to insert \(job?.variant) job before the current one even though it's behaviour is \(job?.behaviour)") return nil default: break } - // Store the job into the database (getting an id for it) - guard let updatedJob: Job = try? job?.inserted(db) else { - SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job") - return nil - } - guard let jobId: Int64 = updatedJob.id else { - SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") - return nil - } + guard + let updatedJob: Job = validatedJob(db, job: job, validation: .persist), + let jobId: Int64 = updatedJob.id + else { return nil } queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) @@ -729,6 +737,43 @@ public final class JobRunner: JobRunnerType { appHasBecomeActive.wrappedValue ) } + + private func validatedJob(_ db: Database, job: Job?, validation: Validation) -> Job? { + guard let job: Job = job else { return nil } + + switch (validation, job.uniqueHashValue) { + case (.enqueueOnly, .none): return job + case (.enqueueOnly, .some(let uniqueHashValue)): + // Nothing currently running or sitting in a JobQueue + guard !allJobInfo().contains(where: { _, info -> Bool in info.uniqueHashValue == uniqueHashValue }) else { + SNLog("[JobRunner] Unable to add \(job.variant) job due to unique constraint") + return nil + } + + return job + + case (.persist, .some(let uniqueHashValue)): + guard + // Nothing currently running or sitting in a JobQueue + !allJobInfo().contains(where: { _, info -> Bool in info.uniqueHashValue == uniqueHashValue }) && + // Nothing in the database + !Job.filter(Job.Columns.uniqueHashValue == uniqueHashValue).isNotEmpty(db) + else { + SNLog("[JobRunner] Unable to add \(job.variant) job due to unique constraint") + return nil + } + + fallthrough // Validation passed so try to persist the job + + case (.persist, .none): + guard let updatedJob: Job = try? job.inserted(db), updatedJob.id != nil else { + SNLog("[JobRunner] Unable to add \(job.variant) job\(job.id == nil ? " due to missing id" : "")") + return nil + } + + return updatedJob + } + } } // MARK: - JobQueue @@ -740,6 +785,7 @@ public final class JobQueue: Hashable { case messageSend case messageReceive case attachmentDownload + case displayPictureDownload case expirationUpdate var name: String { @@ -749,6 +795,7 @@ public final class JobQueue: Hashable { case .messageSend: return "MessageSend" case .messageReceive: return "MessageReceive" case .attachmentDownload: return "AttachmentDownload" + case .displayPictureDownload: return "DisplayPictureDownload" case .expirationUpdate: return "ExpirationUpdate" } } @@ -1303,7 +1350,8 @@ public final class JobQueue: Hashable { variant: nextJob.variant, threadId: nextJob.threadId, interactionId: nextJob.interactionId, - detailsData: nextJob.details + detailsData: nextJob.details, + uniqueHashValue: nextJob.uniqueHashValue ) ) } @@ -1707,3 +1755,15 @@ public final class JobQueue: Hashable { } } } + +// MARK: - Formatting + +extension String.StringInterpolation { + mutating func appendInterpolation(_ variant: Job.Variant?) { + appendLiteral(variant.map { "\($0)" } ?? "unknown") // stringlint:disable + } + + mutating func appendInterpolation(_ behaviour: Job.Behaviour?) { + appendLiteral(behaviour.map { "\($0)" } ?? "unknown") // stringlint:disable + } +} diff --git a/SessionUtilitiesKitTests/General/SessionIdSpec.swift b/SessionUtilitiesKitTests/General/SessionIdSpec.swift index 919ea1939..99ec4f989 100644 --- a/SessionUtilitiesKitTests/General/SessionIdSpec.swift +++ b/SessionUtilitiesKitTests/General/SessionIdSpec.swift @@ -98,8 +98,7 @@ class SessionIdSpec: QuickSpec { // MARK: ------ throws with the wrong length it("throws with the wrong length") { - expect(try SessionId.Prefix(from: "05\(String(TestConstants.publicKey.prefix(10)))")) - .to(throwError(SessionIdError.invalidLength)) + expect(try SessionId.Prefix(from: "0")).to(throwError(SessionIdError.invalidLength)) } // MARK: ------ throws with an invalid prefix diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift index 2c11f6d17..a11e4dba5 100644 --- a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -18,6 +18,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -30,6 +31,7 @@ class JobRunnerSpec: QuickSpec { variant: .attachmentUpload, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -79,6 +81,7 @@ class JobRunnerSpec: QuickSpec { variant: .getSnodePool, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -173,6 +176,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnceNextLaunch, shouldBlock: true, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -229,6 +233,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -243,6 +248,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -276,7 +282,8 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, threadId: nil, interactionId: nil, - detailsData: job1.details + detailsData: job1.details, + uniqueHashValue: nil ) ])) expect(jobRunner.jobInfoFor(jobs: [job2])) @@ -285,7 +292,8 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, threadId: nil, interactionId: nil, - detailsData: nil + detailsData: nil, + uniqueHashValue: nil ) ])) } @@ -298,6 +306,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -312,6 +321,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -348,7 +358,8 @@ class JobRunnerSpec: QuickSpec { interactionId: nil, detailsData: try! JSONEncoder() .with(outputFormatting: .sortedKeys) - .encode(TestDetails(completeTime: 1)) + .encode(TestDetails(completeTime: 1)), + uniqueHashValue: nil ) ])) expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) @@ -362,6 +373,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -376,6 +388,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -412,7 +425,8 @@ class JobRunnerSpec: QuickSpec { interactionId: nil, detailsData: try! JSONEncoder() .with(outputFormatting: .sortedKeys) - .encode(TestDetails(completeTime: 1)) + .encode(TestDetails(completeTime: 1)), + uniqueHashValue: nil ) ])) expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) @@ -450,7 +464,8 @@ class JobRunnerSpec: QuickSpec { interactionId: nil, detailsData: try! JSONEncoder() .with(outputFormatting: .sortedKeys) - .encode(TestDetails(completeTime: 2)) + .encode(TestDetails(completeTime: 2)), + uniqueHashValue: nil ) ])) expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) @@ -479,7 +494,8 @@ class JobRunnerSpec: QuickSpec { interactionId: nil, detailsData: try! JSONEncoder() .with(outputFormatting: .sortedKeys) - .encode(TestDetails(completeTime: 1)) + .encode(TestDetails(completeTime: 1)), + uniqueHashValue: nil ) ])) } @@ -492,6 +508,7 @@ class JobRunnerSpec: QuickSpec { variant: .attachmentUpload, behaviour: .runOnceNextLaunch, shouldBlock: true, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -520,7 +537,8 @@ class JobRunnerSpec: QuickSpec { interactionId: nil, detailsData: try! JSONEncoder() .with(outputFormatting: .sortedKeys) - .encode(TestDetails(completeTime: 1)) + .encode(TestDetails(completeTime: 1)), + uniqueHashValue: nil ) ])) } @@ -560,6 +578,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -574,6 +593,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -636,6 +656,7 @@ class JobRunnerSpec: QuickSpec { variant: .attachmentUpload, behaviour: .runOnceNextLaunch, shouldBlock: true, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -749,6 +770,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnceNextLaunch, shouldBlock: true, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -793,6 +815,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .recurringOnActive, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -807,6 +830,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnceNextLaunch, shouldBlock: true, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -874,6 +898,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .recurringOnActive, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -888,6 +913,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -933,6 +959,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnceNextLaunch, shouldBlock: true, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -987,6 +1014,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnceNextLaunch, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -1034,6 +1062,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -1063,6 +1092,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .recurringOnLaunch, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -1092,6 +1122,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -1122,6 +1153,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .recurringOnLaunch, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -1152,6 +1184,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .runOnce, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -1183,6 +1216,7 @@ class JobRunnerSpec: QuickSpec { variant: .messageSend, behaviour: .recurringOnLaunch, shouldBlock: false, + shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, @@ -1523,7 +1557,8 @@ class JobRunnerSpec: QuickSpec { interactionId: nil, detailsData: try! JSONEncoder() .with(outputFormatting: .sortedKeys) - .encode(TestDetails(result: .deferred, completeTime: 3)) + .encode(TestDetails(result: .deferred, completeTime: 3)), + uniqueHashValue: nil ) ])) dependencies.stepForwardInTime() @@ -1541,7 +1576,8 @@ class JobRunnerSpec: QuickSpec { interactionId: nil, detailsData: try! JSONEncoder() .with(outputFormatting: .sortedKeys) - .encode(TestDetails(result: .deferred, completeTime: 5)) + .encode(TestDetails(result: .deferred, completeTime: 5)), + uniqueHashValue: nil ) ])) dependencies.stepForwardInTime() @@ -1559,7 +1595,8 @@ class JobRunnerSpec: QuickSpec { interactionId: nil, detailsData: try! JSONEncoder() .with(outputFormatting: .sortedKeys) - .encode(TestDetails(result: .deferred, completeTime: 7)) + .encode(TestDetails(result: .deferred, completeTime: 7)), + uniqueHashValue: nil ) ])) dependencies.stepForwardInTime()