From a7af1ca7680630a72496c8b9973ff71f16b2b8d9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 3 Apr 2023 17:35:46 +1000 Subject: [PATCH 01/50] Started laying the ground work for unit testing the JobRunnner Starting injecting dependencies for the JobRunner Turned the JobRunner into a singleton instance --- Session.xcodeproj/project.pbxproj | 12 + Session/Notifications/SyncPushTokensJob.swift | 23 +- Session/Utilities/BackgroundPoller.swift | 6 +- .../Database/Models/Profile.swift | 4 +- .../Jobs/Types/AttachmentDownloadJob.swift | 27 +- .../Jobs/Types/AttachmentUploadJob.swift | 21 +- .../Jobs/Types/DisappearingMessagesJob.swift | 11 +- .../Types/FailedAttachmentDownloadsJob.swift | 11 +- .../Jobs/Types/FailedMessageSendsJob.swift | 11 +- .../Jobs/Types/GarbageCollectionJob.swift | 15 +- .../Jobs/Types/MessageReceiveJob.swift | 17 +- .../Jobs/Types/MessageSendJob.swift | 40 +- .../Jobs/Types/NotifyPushServerJob.swift | 13 +- .../RetrieveDefaultOpenGroupRoomsJob.swift | 15 +- .../Jobs/Types/SendReadReceiptsJob.swift | 19 +- .../Jobs/Types/UpdateProfilePictureJob.swift | 19 +- .../Sending & Receiving/MessageSender.swift | 6 +- .../Pollers/ClosedGroupPoller.swift | 6 +- SessionSnodeKit/GetSnodePoolJob.swift | 21 +- .../Utilities/Database+Utilities.swift | 90 +++ SessionUtilitiesKit/JobRunner/JobRunner.swift | 637 ++++++++++++------ .../JobRunner/JobRunnerSpec.swift | 68 ++ 22 files changed, 740 insertions(+), 352 deletions(-) create mode 100644 SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 512fbb126..e8773d8c6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -825,6 +825,7 @@ FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; + FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; @@ -1902,6 +1903,7 @@ FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; + FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = ""; }; FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LintLocalizableStrings.swift; sourceTree = ""; }; FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = ""; }; @@ -3908,6 +3910,7 @@ children = ( FD37EA1228AB3F60003AE748 /* Database */, FD83B9B927CF20A5005E1583 /* General */, + FDDF074829DAB35200E5E8B5 /* JobRunner */, ); path = SessionUtilitiesKitTests; sourceTree = ""; @@ -4112,6 +4115,14 @@ path = Models; sourceTree = ""; }; + FDDF074829DAB35200E5E8B5 /* JobRunner */ = { + isa = PBXGroup; + children = ( + FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */, + ); + path = JobRunner; + sourceTree = ""; + }; FDE7214E287E50D50093DF33 /* Scripts */ = { isa = PBXGroup; children = ( @@ -5804,6 +5815,7 @@ FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */, FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */, FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */, + FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */, FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 61fb77c80..e6206b300 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -16,16 +16,17 @@ public enum SyncPushTokensJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { // Don't run when inactive or not in main app or if the user doesn't exist yet guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false), Identity.userExists() else { - deferred(job) // Don't need to do anything if it's not the main app + deferred(job, dependencies) // Don't need to do anything if it's not the main app return } @@ -33,7 +34,7 @@ public enum SyncPushTokensJob: JobExecutor { // the main thread then swap to it guard Thread.isMainThread else { DispatchQueue.main.async { - run(job, queue: queue, success: success, failure: failure, deferred: deferred) + run(job, queue: queue, success: success, failure: failure, deferred: deferred, dependencies: dependencies) } return } @@ -61,7 +62,7 @@ public enum SyncPushTokensJob: JobExecutor { !UIApplication.shared.isRegisteredForRemoteNotifications || Date().timeIntervalSince(lastPushNotificationSync) >= SyncPushTokensJob.maxFrequency else { - deferred(job) // Don't need to do anything if push notifications are already registered + deferred(job, dependencies) // Don't need to do anything if push notifications are already registered return } @@ -90,7 +91,9 @@ public enum SyncPushTokensJob: JobExecutor { } } } - .ensure(on: queue) { success(job, false) } // We want to complete this job regardless of success or failure + .ensure(on: queue) { + success(job, false, dependencies) // We want to complete this job regardless of success or failure + } .retainUntilComplete() } @@ -107,9 +110,9 @@ public enum SyncPushTokensJob: JobExecutor { SyncPushTokensJob.run( job, queue: DispatchQueue.global(qos: .default), - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in } + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in } ) } } diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 391f8005a..af1267fdd 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -171,9 +171,9 @@ public final class BackgroundPoller { MessageReceiveJob.run( job, queue: DispatchQueue.main, - success: { _, _ in seal.fulfill(()) }, - failure: { _, _, _ in seal.fulfill(()) }, - deferred: { _ in seal.fulfill(()) } + success: { _, _, _ in seal.fulfill(()) }, + failure: { _, _, _, _ in seal.fulfill(()) }, + deferred: { _, _ in seal.fulfill(()) } ) return promise diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 38f7c4cd9..0a32ea093 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -258,10 +258,10 @@ public extension Profile { /// /// **Note:** This method intentionally does **not** save the newly created Profile, /// it will need to be explicitly saved after calling - static func fetchOrCreateCurrentUser() -> Profile { + static func fetchOrCreateCurrentUser(dependencies: Dependencies = Dependencies()) -> Profile { var userPublicKey: String = "" - let exisingProfile: Profile? = Storage.shared.read { db in + let exisingProfile: Profile? = dependencies.storage.read { db in userPublicKey = getUserHexEncodedPublicKey(db) return try Profile.fetchOne(db, id: userPublicKey) diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index a3588e511..14934a4e7 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -14,9 +14,10 @@ public enum AttachmentDownloadJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { guard let threadId: String = job.threadId, @@ -25,7 +26,7 @@ public enum AttachmentDownloadJob: JobExecutor { let attachment: Attachment = Storage.shared .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) else { - failure(job, JobRunnerError.missingRequiredDetails, false) + failure(job, JobRunnerError.missingRequiredDetails, false, dependencies) return } @@ -33,7 +34,7 @@ public enum AttachmentDownloadJob: JobExecutor { // an AttachmentDownloadJob to get created for an attachment which has already been // downloaded/uploaded so in those cases just succeed immediately guard attachment.state != .downloaded && attachment.state != .uploaded else { - success(job, false) + success(job, false, dependencies) return } @@ -42,7 +43,7 @@ public enum AttachmentDownloadJob: JobExecutor { // if an attachment ends up stuck in a "downloading" state incorrectly guard attachment.state != .downloading else { let otherCurrentJobAttachmentIds: Set = JobRunner - .defailsForCurrentlyRunningJobs(of: .attachmentDownload) + .detailsForCurrentlyRunningJobs(of: .attachmentDownload) .filter { key, _ in key != job.id } .values .compactMap { data -> String? in @@ -57,7 +58,7 @@ public enum AttachmentDownloadJob: JobExecutor { // then we should update the state of the attachment to be failed to avoid having attachments // appear in an endlessly downloading state if !otherCurrentJobAttachmentIds.contains(attachment.id) { - Storage.shared.write { db in + dependencies.storage.write { db in _ = try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) @@ -70,12 +71,12 @@ public enum AttachmentDownloadJob: JobExecutor { // If there is another current job then just fail this one permanently, otherwise let it // retry (if there are more retry attempts available) and in the next retry it's state should // be 'failedDownload' so we won't get stuck in a loop - failure(job, nil, otherCurrentJobAttachmentIds.contains(attachment.id)) + failure(job, nil, otherCurrentJobAttachmentIds.contains(attachment.id), dependencies) return } // Update to the 'downloading' state (no need to update the 'attachment' instance) - Storage.shared.write { db in + dependencies.storage.write { db in try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.downloading)) @@ -141,7 +142,7 @@ public enum AttachmentDownloadJob: JobExecutor { /// /// **Note:** We **MUST** use the `'with()` function here as it will update the /// `isValid` and `duration` values based on the downloaded data and the state - Storage.shared.write { db in + dependencies.storage.write { db in _ = try attachment .with( state: .downloaded, @@ -154,7 +155,7 @@ public enum AttachmentDownloadJob: JobExecutor { .saved(db) } - success(job, false) + success(job, false, dependencies) } .catch(on: queue) { error in OWSFileSystem.deleteFile(temporaryFileUrl.path) @@ -188,14 +189,14 @@ public enum AttachmentDownloadJob: JobExecutor { /// /// **Note:** We **MUST** use the `'with()` function here as it will update the /// `isValid` and `duration` values based on the downloaded data and the state - Storage.shared.write { db in + dependencies.storage.write { db in _ = try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: targetState)) } /// Trigger the failure and provide the `permanentFailure` value defined above - failure(job, error, permanentFailure) + failure(job, error, permanentFailure, dependencies) } } } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 88e72891f..eb47948fc 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -14,16 +14,17 @@ public enum AttachmentUploadJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { guard let threadId: String = job.threadId, let interactionId: Int64 = job.interactionId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), - let (attachment, openGroup): (Attachment, OpenGroup?) = Storage.shared.read({ db in + let (attachment, openGroup): (Attachment, OpenGroup?) = dependencies.storage.read({ db in guard let attachment: Attachment = try Attachment.fetchOne(db, id: details.attachmentId) else { return nil } @@ -31,20 +32,20 @@ public enum AttachmentUploadJob: JobExecutor { return (attachment, try OpenGroup.fetchOne(db, id: threadId)) }) else { - failure(job, JobRunnerError.missingRequiredDetails, false) + failure(job, JobRunnerError.missingRequiredDetails, false, dependencies) return } // If the original interaction no longer exists then don't bother uploading the attachment (ie. the // message was deleted before it even got sent) - guard Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { - failure(job, StorageError.objectNotFound, true) + guard dependencies.storage.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { + failure(job, StorageError.objectNotFound, true, dependencies) return } // If the attachment is still pending download the hold off on running this job guard attachment.state != .pendingDownload && attachment.state != .downloading else { - deferred(job) + deferred(job, dependencies) return } @@ -71,8 +72,8 @@ public enum AttachmentUploadJob: JobExecutor { .map { response -> String in response.id } }, encrypt: (openGroup == nil), - success: { _ in success(job, false) }, - failure: { error in failure(job, error, false) } + success: { _ in success(job, false, dependencies) }, + failure: { error in failure(job, error, false, dependencies) } ) } } diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index d294c27b3..d1dc38b27 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -13,15 +13,16 @@ public enum DisappearingMessagesJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { // The 'backgroundTask' gets captured and cleared within the 'completion' block let timestampNowMs: TimeInterval = TimeInterval(SnodeAPI.currentOffsetTimestampMs()) var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: #function) - let updatedJob: Job? = Storage.shared.write { db in + let updatedJob: Job? = dependencies.storage.write { db in _ = try Interaction .filter(Interaction.Columns.expiresStartedAtMs != nil) .filter((Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)) <= timestampNowMs) @@ -35,7 +36,7 @@ public enum DisappearingMessagesJob: JobExecutor { .saved(db) } - success(updatedJob ?? job, false) + success(updatedJob ?? job, false, dependencies) // The 'if' is only there to prevent the "variable never read" warning from showing if backgroundTask != nil { backgroundTask = nil } diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index a2d921eee..7c1473f00 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -13,12 +13,13 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { // Update all 'sending' message states to 'failed' - Storage.shared.write { db in + dependencies.storage.write { db in let changeCount: Int = try Attachment .filter(Attachment.Columns.state == Attachment.State.downloading) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) @@ -26,6 +27,6 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { Logger.debug("Marked \(changeCount) attachments as failed") } - success(job, false) + success(job, false, dependencies) } } diff --git a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index bdb53d53a..4b4d5c4d1 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -13,12 +13,13 @@ public enum FailedMessageSendsJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { // Update all 'sending' message states to 'failed' - Storage.shared.write { db in + dependencies.storage.write { db in let sendChangeCount: Int = try RecipientState .filter(RecipientState.Columns.state == RecipientState.State.sending) .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed)) @@ -33,6 +34,6 @@ public enum FailedMessageSendsJob: JobExecutor { SNLog("Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)") } - success(job, false) + success(job, false, dependencies) } } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 512e61bfd..46d776cd3 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -21,9 +21,10 @@ public enum GarbageCollectionJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { /// Determine what types of data we want to collect (if we didn't provide any then assume we want to collect everything) /// @@ -57,7 +58,7 @@ public enum GarbageCollectionJob: JobExecutor { return typesToCollect.asSet() }() - Storage.shared.writeAsync( + dependencies.storage.writeAsync( updates: { db in /// Remove any typing indicators if finalTypesToCollect.contains(.threadTypingIndicators) { @@ -339,7 +340,7 @@ public enum GarbageCollectionJob: JobExecutor { // If we couldn't get the file lists then fail (invalid state and don't want to delete all attachment/profile files) guard let fileInfo: FileInfo = maybeFileInfo else { - failure(job, StorageError.generic, false) + failure(job, StorageError.generic, false, dependencies) return } @@ -414,7 +415,7 @@ public enum GarbageCollectionJob: JobExecutor { // Report a single file deletion as a job failure (even if other content was successfully removed) guard deletionErrors.isEmpty else { - failure(job, (deletionErrors.first ?? StorageError.generic), false) + failure(job, (deletionErrors.first ?? StorageError.generic), false, dependencies) return } @@ -424,7 +425,7 @@ public enum GarbageCollectionJob: JobExecutor { UserDefaults.standard[.lastGarbageCollection] = Date() } - success(job, false) + success(job, false, dependencies) } } ) diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 907f6af8d..e12265c18 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -13,22 +13,23 @@ public enum MessageReceiveJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { guard let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, false) + failure(job, JobRunnerError.missingRequiredDetails, false, dependencies) return } var updatedJob: Job = job var leastSevereError: Error? - Storage.shared.write { db in + dependencies.storage.write { db in var remainingMessagesToProcess: [Details.MessageInfo] = [] for messageInfo in details.messages { @@ -86,13 +87,13 @@ public enum MessageReceiveJob: JobExecutor { // Handle the result switch leastSevereError { case let error as MessageReceiverError where !error.isRetryable: - failure(updatedJob, error, true) + failure(updatedJob, error, true, dependencies) case .some(let error): - failure(updatedJob, error, false) + failure(updatedJob, error, false, dependencies) case .none: - success(updatedJob, false) + success(updatedJob, false, dependencies) } } } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index bae02f89c..050dc1bfa 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -15,15 +15,16 @@ public enum MessageSendJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { guard let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, false) + failure(job, JobRunnerError.missingRequiredDetails, false, dependencies) return } @@ -36,14 +37,14 @@ public enum MessageSendJob: JobExecutor { let jobId: Int64 = job.id, let interactionId: Int64 = job.interactionId else { - failure(job, JobRunnerError.missingRequiredDetails, false) + failure(job, JobRunnerError.missingRequiredDetails, false, dependencies) return } // If the original interaction no longer exists then don't bother sending the message (ie. the // message was deleted before it even got sent) - guard Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { - failure(job, StorageError.objectNotFound, true) + guard dependencies.storage.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { + failure(job, StorageError.objectNotFound, true, dependencies) return } @@ -52,7 +53,7 @@ public enum MessageSendJob: JobExecutor { // // Note: Normal attachments should be sent in a non-durable way but any // attachments for LinkPreviews and Quotes will be processed through this mechanism - let attachmentState: (shouldFail: Bool, shouldDefer: Bool, fileIds: [String])? = Storage.shared.write { db in + let attachmentState: (shouldFail: Bool, shouldDefer: Bool, fileIds: [String])? = dependencies.storage.write { db in let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment .stateInfo(interactionId: interactionId) .fetchAll(db) @@ -110,7 +111,8 @@ public enum MessageSendJob: JobExecutor { attachmentId: stateInfo.attachmentId ) ), - before: job + before: job, + dependencies: dependencies ) } .forEach { otherJobId, _ in @@ -140,13 +142,13 @@ public enum MessageSendJob: JobExecutor { // Note: If we have gotten to this point then any dependant attachment upload // jobs will have permanently failed so this message send should also do so guard attachmentState?.shouldFail == false else { - failure(job, AttachmentError.notUploaded, true) + failure(job, AttachmentError.notUploaded, true, dependencies) return } // Defer the job if we found incomplete uploads guard attachmentState?.shouldDefer == false else { - deferred(job) + deferred(job, dependencies) return } @@ -161,7 +163,7 @@ public enum MessageSendJob: JobExecutor { details.message.threadId = (details.message.threadId ?? job.threadId) // Perform the actual message sending - Storage.shared.writeAsync { db -> Promise in + dependencies.storage.writeAsync { db -> Promise in try MessageSender.sendImmediate( db, message: details.message, @@ -171,20 +173,20 @@ public enum MessageSendJob: JobExecutor { isSyncMessage: (details.isSyncMessage == true) ) } - .done(on: queue) { _ in success(job, false) } + .done(on: queue) { _ in success(job, false, dependencies) } .catch(on: queue) { error in SNLog("Couldn't send message due to error: \(error).") switch error { case let senderError as MessageSenderError where !senderError.isRetryable: - failure(job, error, true) + failure(job, error, true, dependencies) case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited - failure(job, error, true) + failure(job, error, true, dependencies) case SnodeAPIError.clockOutOfSync: SNLog("\(originalSentTimestamp != nil ? "Permanently Failing" : "Failing") to send \(type(of: details.message)) due to clock out of sync issue.") - failure(job, error, (originalSentTimestamp != nil)) + failure(job, error, (originalSentTimestamp != nil), dependencies) default: SNLog("Failed to send \(type(of: details.message)).") @@ -192,15 +194,15 @@ public enum MessageSendJob: JobExecutor { if details.message is VisibleMessage { guard let interactionId: Int64 = job.interactionId, - Storage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true + dependencies.storage.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { // The message has been deleted so permanently fail the job - failure(job, error, true) + failure(job, error, true, dependencies) return } } - failure(job, error, false) + failure(job, error, false, dependencies) } } .retainUntilComplete() diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index 63885541a..3be8e20ab 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -13,15 +13,16 @@ public enum NotifyPushServerJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { guard let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, false) + failure(job, JobRunnerError.missingRequiredDetails, false, dependencies) return } @@ -32,8 +33,8 @@ public enum NotifyPushServerJob: JobExecutor { maxRetryCount: 4, queue: queue ) - .done(on: queue) { _ in success(job, false) } - .catch(on: queue) { error in failure(job, error, false) } + .done(on: queue) { _ in success(job, false, dependencies) } + .catch(on: queue) { error in failure(job, error, false, dependencies) } .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift index 01c244019..87c09ae50 100644 --- a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -13,13 +13,14 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { // Don't run when inactive or not in main app guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { - deferred(job) // Don't need to do anything if it's not the main app + deferred(job, dependencies) // Don't need to do anything if it's not the main app return } @@ -27,7 +28,7 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { // in the database so we need to create a dummy one to retrieve the default room data let defaultGroupId: String = OpenGroup.idFor(roomToken: "", server: OpenGroupAPI.defaultServer) - Storage.shared.write { db in + dependencies.storage.write { db in guard try OpenGroup.exists(db, id: defaultGroupId) == false else { return } _ = try OpenGroup( @@ -43,8 +44,8 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { } OpenGroupManager.getDefaultRoomsIfNeeded() - .done(on: queue) { _ in success(job, false) } - .catch(on: queue) { error in failure(job, error, false) } + .done(on: queue) { _ in success(job, false, dependencies) } + .catch(on: queue) { error in failure(job, error, false, dependencies) } .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index bdb684869..0082bd277 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -14,16 +14,17 @@ public enum SendReadReceiptsJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { guard let threadId: String = job.threadId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, false) + failure(job, JobRunnerError.missingRequiredDetails, false, dependencies) return } @@ -31,11 +32,11 @@ public enum SendReadReceiptsJob: JobExecutor { // something is marked as read we want to try and run immediately so don't scuedule // another run in this case) guard !details.timestampMsValues.isEmpty else { - success(job, true) + success(job, true, dependencies) return } - Storage.shared + dependencies.storage .writeAsync { db in try MessageSender.sendImmediate( db, @@ -54,7 +55,7 @@ public enum SendReadReceiptsJob: JobExecutor { var shouldFinishCurrentJob: Bool = false let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + minRunFrequency) - let updatedJob: Job? = Storage.shared.write { db in + let updatedJob: Job? = dependencies.storage.write { db in // If another 'sendReadReceipts' job was scheduled then update that one // to run at 'nextRunTimestamp' and make the current job stop if @@ -79,9 +80,9 @@ public enum SendReadReceiptsJob: JobExecutor { .saved(db) } - success(updatedJob ?? job, shouldFinishCurrentJob) + success(updatedJob ?? job, shouldFinishCurrentJob, dependencies) } - .catch(on: queue) { error in failure(job, error, false) } + .catch(on: queue) { error in failure(job, error, false, dependencies) } .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index 0a79dfde9..ee7d69125 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -13,13 +13,14 @@ public enum UpdateProfilePictureJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { // Don't run when inactive or not in main app guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { - deferred(job) // Don't need to do anything if it's not the main app + deferred(job, dependencies) // Don't need to do anything if it's not the main app return } @@ -31,18 +32,18 @@ public enum UpdateProfilePictureJob: JobExecutor { // Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck // in a loop endlessly deferring the job if let jobId: Int64 = job.id { - Storage.shared.write { db in + dependencies.storage.write { db in try Job .filter(id: jobId) .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) } } - deferred(job) + deferred(job, dependencies) return } // Note: The user defaults flag is updated in ProfileManager - let profile: Profile = Profile.fetchOrCreateCurrentUser() + let profile: Profile = Profile.fetchOrCreateCurrentUser(dependencies: dependencies) let profileFilePath: String? = profile.profilePictureFileName .map { ProfileManager.profileAvatarFilepath(filename: $0) } @@ -58,10 +59,10 @@ public enum UpdateProfilePictureJob: JobExecutor { // issue as it will write to the database and this closure is already called within // another database write queue.async { - success(job, false) + success(job, false, dependencies) } }, - failure: { error in failure(job, error, false) } + failure: { error in failure(job, error, false, dependencies) } ) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 32bb8cbc5..6c02a3ccb 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -270,12 +270,12 @@ public final class MessageSender { NotifyPushServerJob.run( job, queue: DispatchQueue.global(qos: .default), - success: { _, _ in seal.fulfill(()) }, - failure: { _, _, _ in + success: { _, _, _ in seal.fulfill(()) }, + failure: { _, _, _, _ in // Always fulfill because the notify PN server job isn't critical. seal.fulfill(()) }, - deferred: { _ in + deferred: { _, _ in // Always fulfill because the notify PN server job isn't critical. seal.fulfill(()) } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 369959742..801968e12 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -284,9 +284,9 @@ public final class ClosedGroupPoller { MessageReceiveJob.run( job, queue: queue, - success: { _, _ in seal.fulfill(()) }, - failure: { _, _, _ in seal.fulfill(()) }, - deferred: { _ in seal.fulfill(()) } + success: { _, _, _ in seal.fulfill(()) }, + failure: { _, _, _, _ in seal.fulfill(()) }, + deferred: { _, _ in seal.fulfill(()) } ) return promise diff --git a/SessionSnodeKit/GetSnodePoolJob.swift b/SessionSnodeKit/GetSnodePoolJob.swift index af0c61d07..610101c80 100644 --- a/SessionSnodeKit/GetSnodePoolJob.swift +++ b/SessionSnodeKit/GetSnodePoolJob.swift @@ -13,14 +13,15 @@ public enum GetSnodePoolJob: JobExecutor { public static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() ) { // If the user doesn't exist then don't do anything (when the user registers we run this // job directly) guard Identity.userExists() else { - deferred(job) + deferred(job, dependencies) return } @@ -30,13 +31,13 @@ public enum GetSnodePoolJob: JobExecutor { // wait if we already have a potentially valid snode pool guard !SnodeAPI.hasCachedSnodesInclusingExpired() else { SnodeAPI.getSnodePool().retainUntilComplete() - success(job, false) + success(job, false, dependencies) return } SnodeAPI.getSnodePool() - .done(on: queue) { _ in success(job, false) } - .catch(on: queue) { error in failure(job, error, false) } + .done(on: queue) { _ in success(job, false, dependencies) } + .catch(on: queue) { error in failure(job, error, false, dependencies) } .retainUntilComplete() } @@ -44,9 +45,9 @@ public enum GetSnodePoolJob: JobExecutor { GetSnodePoolJob.run( Job(variant: .getSnodePool), queue: DispatchQueue.global(qos: .background), - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in } + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in } ) } } diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift index b8e849c74..e90b90909 100644 --- a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -36,4 +36,94 @@ public extension Database { sqlite3_interrupt(sqliteConnection) } + + /// This is a custom implementation of the `afterNextTransaction` method which executes the closures within their own + /// transactions to allow for nesting of 'afterNextTransaction' actions + /// + /// **Note:** GRDB doesn't notify read-only transactions to transaction observers + func afterNextTransactionNested( + onCommit: @escaping (Database) -> Void, + onRollback: @escaping (Database) -> Void = { _ in } + ) { + afterNextTransactionNestedOnce( + dedupeId: UUID().uuidString, + onCommit: onCommit, + onRollback: onRollback + ) + } + + func afterNextTransactionNestedOnce( + dedupeId: String, + onCommit: @escaping (Database) -> Void, + onRollback: @escaping (Database) -> Void = { _ in } + ) { + // Only allow a single observer per `dedupeId` per transaction, this allows us to + // schedule an action to run at most once per transaction (eg. auto-scheduling a ConfigSyncJob + // when receiving messages) + guard !TransactionHandler.registeredHandlers.wrappedValue.contains(dedupeId) else { + return + } + + add( + transactionObserver: TransactionHandler( + identifier: dedupeId, + onCommit: onCommit, + onRollback: onRollback + ), + extent: .nextTransaction + ) + } } + +fileprivate class TransactionHandler: TransactionObserver { + static var registeredHandlers: Atomic> = Atomic([]) + + let identifier: String + let onCommit: (Database) -> Void + let onRollback: (Database) -> Void + + init( + identifier: String, + onCommit: @escaping (Database) -> Void, + onRollback: @escaping (Database) -> Void + ) { + self.identifier = identifier + self.onCommit = onCommit + self.onRollback = onRollback + + TransactionHandler.registeredHandlers.mutate { $0.insert(identifier) } + } + + // Ignore changes + func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { false } + func databaseDidChange(with event: DatabaseEvent) { } + + func databaseDidCommit(_ db: Database) { + TransactionHandler.registeredHandlers.mutate { $0.remove(identifier) } + + do { + try db.inTransaction { + onCommit(db) + return .commit + } + } + catch { + SNLog("[Database] afterNextTransactionNested onCommit failed") + } + } + + func databaseDidRollback(_ db: Database) { + TransactionHandler.registeredHandlers.mutate { $0.remove(identifier) } + + do { + try db.inTransaction { + onRollback(db) + return .commit + } + } + catch { + SNLog("[Database] afterNextTransactionNested onRollback failed") + } + } +} + diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 7d604f0ba..fafb086d2 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import SignalCoreKit public protocol JobExecutor { /// The maximum number of times the job can fail before it fails permanently @@ -29,9 +28,10 @@ public protocol JobExecutor { static func run( _ job: Job, queue: DispatchQueue, - success: @escaping (Job, Bool) -> (), - failure: @escaping (Job, Error?, Bool) -> (), - deferred: @escaping (Job) -> () + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies ) } @@ -43,165 +43,103 @@ public final class JobRunner { case notFound } - private static let blockingQueue: Atomic = Atomic( - JobQueue( - type: .blocking, - qos: .default, - jobVariants: [], - onQueueDrained: { - // Once all blocking jobs have been completed we want to start running - // the remaining job queues - queues.wrappedValue.forEach { _, queue in queue.start() } - } - ) - ) - private static let queues: Atomic<[Job.Variant: JobQueue]> = { + // MARK: - Variables + + private let blockingQueue: Atomic + private let queues: Atomic<[Job.Variant: JobQueue]> + + internal var perSessionJobsCompleted: Atomic> = Atomic([]) + internal var hasCompletedInitialBecomeActive: Atomic = Atomic(false) + internal var shutdownBackgroundTask: Atomic = Atomic(nil) + internal var canStartQueues: Atomic = Atomic(false) + + // MARK: - Initialization + + init(dependencies: Dependencies = Dependencies()) { var jobVariants: Set = Job.Variant.allCases.asSet() - let messageSendQueue: JobQueue = JobQueue( - type: .messageSend, - executionType: .concurrent, // Allow as many jobs to run at once as supported by the device - qos: .default, - jobVariants: [ - jobVariants.remove(.attachmentUpload), - jobVariants.remove(.messageSend), - jobVariants.remove(.notifyPushServer), - jobVariants.remove(.sendReadReceipts) - ].compactMap { $0 } + self.blockingQueue = Atomic( + JobQueue( + type: .blocking, + qos: .default, + jobVariants: [], + onQueueDrained: { + // Once all blocking jobs have been completed we want to start running + // the remaining job queues + JobRunner.startNonBlockingQueues(dependencies: dependencies) + } + ) ) - let messageReceiveQueue: JobQueue = JobQueue( - type: .messageReceive, - // Explicitly serial as executing concurrently means message receives getting processed at - // different speeds which can result in: - // • Small batches of messages appearing in the UI before larger batches - // • Closed group messages encrypted with updated keys could start parsing before it's key - // update message has been processed (ie. guaranteed to fail) - executionType: .serial, - qos: .default, - jobVariants: [ - jobVariants.remove(.messageReceive) - ].compactMap { $0 } - ) - let attachmentDownloadQueue: JobQueue = JobQueue( - type: .attachmentDownload, - qos: .utility, - jobVariants: [ - jobVariants.remove(.attachmentDownload) - ].compactMap { $0 } - ) - let generalQueue: JobQueue = JobQueue( - type: .general(number: 0), - qos: .utility, - jobVariants: Array(jobVariants) - ) - - return Atomic([ - messageSendQueue, - messageReceiveQueue, - attachmentDownloadQueue, - generalQueue + self.queues = Atomic([ + // MARK: -- Message Send Queue + + JobQueue( + type: .messageSend, + executionType: .concurrent, // Allow as many jobs to run at once as supported by the device + qos: .default, + jobVariants: [ + jobVariants.remove(.attachmentUpload), + jobVariants.remove(.messageSend), + jobVariants.remove(.notifyPushServer), + jobVariants.remove(.sendReadReceipts) + ].compactMap { $0 } + ), + + // MARK: -- Message Receive Queue + + JobQueue( + type: .messageReceive, + // Explicitly serial as executing concurrently means message receives getting processed at + // different speeds which can result in: + // • Small batches of messages appearing in the UI before larger batches + // • Closed group messages encrypted with updated keys could start parsing before it's key + // update message has been processed (ie. guaranteed to fail) + executionType: .serial, + qos: .default, + jobVariants: [ + jobVariants.remove(.messageReceive) + ].compactMap { $0 } + ), + + // MARK: -- Attachment Download Queue + + JobQueue( + type: .attachmentDownload, + qos: .utility, + jobVariants: [ + jobVariants.remove(.attachmentDownload) + ].compactMap { $0 } + ), + + // MARK: -- General Queue + + JobQueue( + type: .general(number: 0), + qos: .utility, + jobVariants: Array(jobVariants) + ) ].reduce(into: [:]) { prev, next in next.jobVariants.forEach { variant in prev[variant] = next } }) - }() - - internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) - fileprivate static var perSessionJobsCompleted: Atomic> = Atomic([]) - private static var hasCompletedInitialBecomeActive: Atomic = Atomic(false) - private static var shutdownBackgroundTask: Atomic = Atomic(nil) - fileprivate static var canStartQueues: Atomic = Atomic(false) + } // MARK: - Configuration - public static func add(executor: JobExecutor.Type, for variant: Job.Variant) { - executorMap.mutate { $0[variant] = executor } + internal func add(executor: JobExecutor.Type, for variant: Job.Variant) { + queues.wrappedValue[variant]?.addExecutor(executor, for: variant) } // MARK: - Execution - /// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start - /// the JobRunner - /// - /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` - /// is in the future then the job won't be started - public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) { - // 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 - } - guard !canStartJob || updatedJob.id != nil else { - SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") - return - } - - queues.mutate { $0[updatedJob.variant]?.add(updatedJob, canStartJob: canStartJob) } - - // Don't start the queue if the job can't be started - guard canStartJob else { return } - - // Start the job runner if needed - db.afterNextTransaction { _ in - queues.wrappedValue[updatedJob.variant]?.start() - } - } - - /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start - /// the JobRunner - /// - /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` - /// is in the future then the job won't be started - public static func upsert(_ db: Database, job: Job?, canStartJob: Bool = true) { - guard let job: Job = job else { return } // Ignore null jobs - guard job.id != nil else { - add(db, job: job, canStartJob: canStartJob) - return - } - - queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob) - - // Don't start the queue if the job can't be started - guard canStartJob else { return } - - // Start the job runner if needed - db.afterNextTransaction { _ in - queues.wrappedValue[job.variant]?.start() - } - } - - @discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> (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")") - 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 - } - - queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) - - return (jobId, updatedJob) - } - - public static func appDidFinishLaunching() { + internal func appDidFinishLaunching(dependencies: Dependencies) { // Flag that the JobRunner can start it's queues - JobRunner.canStartQueues.mutate { $0 = true } + canStartQueues.mutate { $0 = true } // Note: 'appDidBecomeActive' will run on first launch anyway so we can // leave those jobs out and can wait until then to start the JobRunner - let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = Storage.shared + let jobsToRun: (blocking: [Job], nonBlocking: [Job]) = dependencies.storage .read { db in let blockingJobs: [Job] = try Job .filter( @@ -231,7 +169,11 @@ public final class JobRunner { guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { return } // Add and start any blocking jobs - blockingQueue.wrappedValue?.appDidFinishLaunching(with: jobsToRun.blocking, canStart: true) + blockingQueue.wrappedValue?.appDidFinishLaunching( + with: jobsToRun.blocking, + canStart: true, + dependencies: dependencies + ) // Add any non-blocking jobs (we don't start these incase there are blocking "on active" // jobs as well) @@ -239,13 +181,13 @@ public final class JobRunner { let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue jobsByVariant.forEach { variant, jobs in - jobQueues[variant]?.appDidFinishLaunching(with: jobs, canStart: false) + jobQueues[variant]?.appDidFinishLaunching(with: jobs, canStart: false, dependencies: dependencies) } } - public static func appDidBecomeActive() { + internal func appDidBecomeActive(dependencies: Dependencies) { // Flag that the JobRunner can start it's queues - JobRunner.canStartQueues.mutate { $0 = true } + canStartQueues.mutate { $0 = true } // If we have a running "sutdownBackgroundTask" then we want to cancel it as otherwise it // can result in the database being suspended and us being unable to interact with it at all @@ -255,8 +197,8 @@ public final class JobRunner { } // Retrieve any jobs which should run when becoming active - let hasCompletedInitialBecomeActive: Bool = JobRunner.hasCompletedInitialBecomeActive.wrappedValue - let jobsToRun: [Job] = Storage.shared + let hasCompletedInitialBecomeActive: Bool = self.hasCompletedInitialBecomeActive.wrappedValue + let jobsToRun: [Job] = dependencies.storage .read { db in return try Job .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) @@ -272,7 +214,7 @@ public final class JobRunner { guard !jobsToRun.isEmpty else { if !blockingQueueIsRunning { - jobQueues.forEach { _, queue in queue.start() } + jobQueues.forEach { _, queue in queue.start(dependencies: dependencies) } } return } @@ -283,23 +225,104 @@ public final class JobRunner { jobQueues.forEach { variant, queue in queue.appDidBecomeActive( with: (jobsByVariant[variant] ?? []), - canStart: !blockingQueueIsRunning + canStart: !blockingQueueIsRunning, + dependencies: dependencies ) } - JobRunner.hasCompletedInitialBecomeActive.mutate { $0 = true } + self.hasCompletedInitialBecomeActive.mutate { $0 = true } } - /// Calling this will clear the JobRunner queues and stop it from running new jobs, any currently executing jobs will continue to run - /// though (this means if we suspend the database it's likely that any currently running jobs will fail to complete and fail to record their - /// failure - they _should_ be picked up again the next time the app is launched) - public static func stopAndClearPendingJobs( + internal func add( + _ db: Database, + job: Job?, + canStartJob: Bool, + dependencies: Dependencies + ) { + // 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 + } + guard !canStartJob || updatedJob.id != nil else { + SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") + return + } + + queues.mutate { + $0[updatedJob.variant]? + .add(updatedJob, canStartJob: canStartJob, dependencies: 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: \(updatedJob.variant)") { [weak self] _ in + self?.queues.wrappedValue[updatedJob.variant]?.start(dependencies: dependencies) + } + } + + internal func upsert( + _ db: Database, + job: Job?, + canStartJob: Bool, + dependencies: Dependencies + ) { + guard let job: Job = job else { return } // Ignore null jobs + guard job.id != nil else { + add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) + return + } + + queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob, dependencies: 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(dependencies: dependencies) + } + } + + @discardableResult internal func insert( + _ db: Database, + job: Job?, + before otherJob: Job, + dependencies: Dependencies + ) -> (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")") + 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 + } + + queues.wrappedValue[updatedJob.variant]? + .insert(updatedJob, before: otherJob, dependencies: dependencies) + + return (jobId, updatedJob) + } + + internal func stopAndClearPendingJobs( exceptForVariant: Job.Variant? = nil, onComplete: (() -> ())? = nil ) { // Inform the JobRunner that it can't start any queues (this is to prevent queues from // rescheduling themselves while in the background, when the app restarts or becomes active // the JobRunenr will update this flag) - JobRunner.canStartQueues.mutate { $0 = false } + canStartQueues.mutate { $0 = false } // Stop all queues except for the one containing the `exceptForVariant` queues.wrappedValue @@ -341,27 +364,27 @@ public final class JobRunner { } // Add a callback to be triggered once the queue is drained - queue.onQueueDrained = { [weak queue] in + queue.onQueueDrained = { [weak self, weak queue] in oldQueueDrained?() queue?.onQueueDrained = oldQueueDrained onComplete?() - shutdownBackgroundTask.mutate { $0 = nil } + self?.shutdownBackgroundTask.mutate { $0 = nil } } } - public static func isCurrentlyRunning(_ job: Job?) -> Bool { + internal func isCurrentlyRunning(_ job: Job?) -> Bool { guard let job: Job = job, let jobId: Int64 = job.id else { return false } return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true) } - public static func defailsForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: Data?] { + internal func detailsForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: Data?] { return (queues.wrappedValue[variant]?.detailsForAllCurrentlyRunningJobs()) .defaulting(to: [:]) } - public static func afterCurrentlyRunningJob(_ job: Job?, callback: @escaping (JobResult) -> ()) { + internal func afterCurrentlyRunningJob(_ job: Job?, callback: @escaping (JobResult) -> ()) { guard let job: Job = job, let jobId: Int64 = job.id, let queue: JobQueue = queues.wrappedValue[job.variant] else { callback(.notFound) return @@ -370,14 +393,14 @@ public final class JobRunner { queue.afterCurrentlyRunningJob(jobId, callback: callback) } - public static func hasPendingOrRunningJob(with variant: Job.Variant, details: T) -> Bool { + internal func hasPendingOrRunningJob(with variant: Job.Variant, details: T) -> Bool { guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false } guard let detailsData: Data = try? JSONEncoder().encode(details) else { return false } return targetQueue.hasPendingOrRunningJob(with: detailsData) } - public static func removePendingJob(_ job: Job?) { + internal func removePendingJob(_ job: Job?) { guard let job: Job = job, let jobId: Int64 = job.id else { return } queues.wrappedValue[job.variant]?.removePendingJob(jobId) @@ -398,6 +421,97 @@ public final class JobRunner { } } +// MARK: - JobRunner Singleton + +public extension JobRunner { + private static let instance: JobRunner = JobRunner() + + // MARK: - Static Access + + static func add(executor: JobExecutor.Type, for variant: Job.Variant) { + instance.add(executor: executor, for: variant) + } + + static func appDidFinishLaunching(dependencies: Dependencies = Dependencies()) { + instance.appDidFinishLaunching(dependencies: dependencies) + } + + static func appDidBecomeActive(dependencies: Dependencies = Dependencies()) { + instance.appDidBecomeActive(dependencies: dependencies) + } + + /// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start + /// the JobRunner + /// + /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` + /// is in the future then the job won't be started + static func add( + _ db: Database, + job: Job?, + canStartJob: Bool = true, + dependencies: Dependencies = Dependencies() + ) { instance.add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } + + /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start + /// the JobRunner + /// + /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` + /// is in the future then the job won't be started + static func upsert( + _ db: Database, + job: Job?, + canStartJob: Bool = true, + dependencies: Dependencies = Dependencies() + ) { instance.upsert(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } + + @discardableResult static func insert( + _ db: Database, + job: Job?, + before otherJob: Job, + dependencies: Dependencies = Dependencies() + ) -> (Int64, Job)? { instance.insert(db, job: job, before: otherJob, dependencies: dependencies) } + + /// Calling this will clear the JobRunner queues and stop it from running new jobs, any currently executing jobs will continue to run + /// though (this means if we suspend the database it's likely that any currently running jobs will fail to complete and fail to record their + /// failure - they _should_ be picked up again the next time the app is launched) + static func stopAndClearPendingJobs( + exceptForVariant: Job.Variant? = nil, + onComplete: (() -> ())? = nil + ) { instance.stopAndClearPendingJobs(exceptForVariant: exceptForVariant, onComplete: onComplete) } + + static func isCurrentlyRunning(_ job: Job?) -> Bool { + return instance.isCurrentlyRunning(job) + } + + static func detailsForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: Data?] { + return instance.detailsForCurrentlyRunningJobs(of: variant) + } + + static func afterCurrentlyRunningJob(_ job: Job?, callback: @escaping (JobResult) -> ()) { + instance.afterCurrentlyRunningJob(job, callback: callback) + } + + static func hasPendingOrRunningJob(with variant: Job.Variant, details: T) -> Bool { + return instance.hasPendingOrRunningJob(with: variant, details: details) + } + + static func removePendingJob(_ job: Job?) { + instance.removePendingJob(job) + } + + // MARK: - Internal Static Access + + fileprivate static func canStart(queue: JobQueue) -> Bool { + return instance.canStartQueues.wrappedValue + } + + fileprivate static func startNonBlockingQueues(dependencies: Dependencies) { + instance.queues.wrappedValue.forEach { _, queue in + queue.start(dependencies: dependencies) + } + } +} + // MARK: - JobQueue private final class JobQueue { @@ -433,7 +547,11 @@ private final class JobQueue { private var timer: Timer? fileprivate var fireTimestamp: TimeInterval = 0 - static func create(queue: JobQueue, timestamp: TimeInterval) -> Trigger? { + static func create( + queue: JobQueue, + timestamp: TimeInterval, + dependencies: Dependencies + ) -> Trigger? { /// Setup the trigger (wait at least 1 second before triggering) /// /// **Note:** We use the `Timer.scheduledTimerOnMainThread` method because running a timer @@ -445,7 +563,7 @@ private final class JobQueue { withTimeInterval: trigger.fireTimestamp, repeats: false, block: { [weak queue] _ in - queue?.start() + queue?.start(dependencies: dependencies) } ) @@ -485,6 +603,7 @@ private final class JobQueue { return result }() + private var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) private var nextTrigger: Atomic = Atomic(nil) fileprivate var isRunning: Atomic = Atomic(false) private var queue: Atomic<[Job]> = Atomic([]) @@ -512,9 +631,15 @@ private final class JobQueue { self.onQueueDrained = onQueueDrained } + // MARK: - Configuration + + fileprivate func addExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { + executorMap.mutate { $0[variant] = executor } + } + // MARK: - Execution - fileprivate func add(_ job: Job, canStartJob: Bool = true) { + fileprivate func add(_ job: Job, canStartJob: Bool = true, dependencies: Dependencies) { // Check if the job should be added to the queue guard canStartJob, @@ -534,7 +659,7 @@ private final class JobQueue { /// /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` /// is in the future then the job won't be started - fileprivate func upsert(_ job: Job, canStartJob: Bool = true) { + fileprivate func upsert(_ job: Job, canStartJob: Bool = true, dependencies: Dependencies) { guard let jobId: Int64 = job.id else { SNLog("[JobRunner] Prevented attempt to upsert \(job.variant) job without id to queue") return @@ -557,10 +682,10 @@ private final class JobQueue { // If we didn't update an existing job then we need to add it to the queue guard !didUpdateExistingJob else { return } - add(job, canStartJob: canStartJob) + add(job, canStartJob: canStartJob, dependencies: dependencies) } - fileprivate func insert(_ job: Job, before otherJob: Job) { + fileprivate func insert(_ job: Job, before otherJob: Job, dependencies: Dependencies) { guard job.id != nil else { SNLog("[JobRunner] Prevented attempt to insert \(job.variant) job without id to queue") return @@ -580,16 +705,24 @@ private final class JobQueue { } } - fileprivate func appDidFinishLaunching(with jobs: [Job], canStart: Bool) { + fileprivate func appDidFinishLaunching( + with jobs: [Job], + canStart: Bool, + dependencies: Dependencies + ) { queue.mutate { $0.append(contentsOf: jobs) } // Start the job runner if needed if canStart && !isRunning.wrappedValue { - start() + start(dependencies: dependencies) } } - fileprivate func appDidBecomeActive(with jobs: [Job], canStart: Bool) { + fileprivate func appDidBecomeActive( + with jobs: [Job], + canStart: Bool, + dependencies: Dependencies + ) { queue.mutate { queue in // Avoid re-adding jobs to the queue that are already in it (this can // happen if the user sends the app to the background before the 'onActive' @@ -602,7 +735,7 @@ private final class JobQueue { // Start the job runner if needed if canStart && !isRunning.wrappedValue { - start() + start(dependencies: dependencies) } } @@ -639,17 +772,24 @@ private final class JobQueue { // MARK: - Job Running - fileprivate func start(force: Bool = false) { + fileprivate func start( + force: Bool = false, + dependencies: Dependencies + ) { // We only want the JobRunner to run in the main app - guard CurrentAppContext().isMainApp else { return } - guard JobRunner.canStartQueues.wrappedValue else { return } + guard + HasAppContext() && + CurrentAppContext().isMainApp && + !CurrentAppContext().isRunningTests && + JobRunner.canStart(queue: self) + else { return } guard force || !isRunning.wrappedValue else { return } // The JobRunner runs synchronously we need to ensure this doesn't start // on the main thread (if it is on the main thread then swap to a different thread) guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { internalQueue.async { [weak self] in - self?.start() + self?.start(dependencies: dependencies) } return } @@ -665,7 +805,7 @@ private final class JobQueue { // Get any pending jobs let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue let jobsAlreadyInQueue: Set = queue.wrappedValue.compactMap { $0.id }.asSet() - let jobsToRun: [Job] = Storage.shared.read { db in + let jobsToRun: [Job] = dependencies.storage.read { db in try Job .filterPendingJobs( variants: jobVariants, @@ -691,7 +831,7 @@ private final class JobQueue { guard jobCount > 0 else { if jobIdsAlreadyRunning.isEmpty { isRunning.mutate { $0 = false } - scheduleNextSoonestJob() + scheduleNextSoonestJob(dependencies: dependencies) } return } @@ -700,7 +840,7 @@ private final class JobQueue { if !wasAlreadyRunning { SNLog("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") } - runNextJob() + runNextJob(dependencies: dependencies) } fileprivate func stopAndClearPendingJobs() { @@ -709,14 +849,14 @@ private final class JobQueue { deferLoopTracker.mutate { $0 = [:] } } - private func runNextJob() { + private func runNextJob(dependencies: Dependencies) { // Ensure the queue is running (if we've stopped the queue then we shouldn't start the next job) guard isRunning.wrappedValue else { return } // Ensure this is running on the correct queue guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { internalQueue.async { [weak self] in - self?.runNextJob() + self?.runNextJob(dependencies: dependencies) } return } @@ -728,38 +868,58 @@ private final class JobQueue { // Always attempt to schedule the next soonest job (otherwise if enough jobs get started in rapid // succession then pending/failed jobs in the database may never get re-started in a concurrent queue) - scheduleNextSoonestJob() + scheduleNextSoonestJob(dependencies: dependencies) return } - guard let jobExecutor: JobExecutor.Type = JobRunner.executorMap.wrappedValue[nextJob.variant] else { + guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else { SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing executor") - handleJobFailed(nextJob, error: JobRunnerError.executorMissing, permanentFailure: true) + handleJobFailed( + nextJob, + error: JobRunnerError.executorMissing, + permanentFailure: true, + dependencies: dependencies + ) return } guard !jobExecutor.requiresThreadId || nextJob.threadId != nil else { SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing required threadId") - handleJobFailed(nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true) + handleJobFailed( + nextJob, + error: JobRunnerError.requiredThreadIdMissing, + permanentFailure: true, + dependencies: dependencies + ) return } guard !jobExecutor.requiresInteractionId || nextJob.interactionId != nil else { SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing required interactionId") - handleJobFailed(nextJob, error: JobRunnerError.requiredInteractionIdMissing, permanentFailure: true) + handleJobFailed( + nextJob, + error: JobRunnerError.requiredInteractionIdMissing, + permanentFailure: true, + dependencies: dependencies + ) return } guard nextJob.id != nil else { SNLog("[JobRunner] \(queueContext) Unable to run \(nextJob.variant) job due to missing id") - handleJobFailed(nextJob, error: JobRunnerError.jobIdMissing, permanentFailure: false) + handleJobFailed( + nextJob, + error: JobRunnerError.jobIdMissing, + permanentFailure: false, + dependencies: dependencies + ) return } // If the 'nextRunTimestamp' for the job is in the future then don't run it yet guard nextJob.nextRunTimestamp <= Date().timeIntervalSince1970 else { - handleJobDeferred(nextJob) + handleJobDeferred(nextJob, dependencies: dependencies) return } // Check if the next job has any dependencies - let dependencyInfo: (expectedCount: Int, jobs: [Job]) = Storage.shared.read { db in + let dependencyInfo: (expectedCount: Int, jobs: [Job]) = dependencies.storage.read { db in let numExpectedDependencies: Int = try JobDependencies .filter(JobDependencies.Columns.jobId == nextJob.id) .fetchCount(db) @@ -771,7 +931,12 @@ private final class JobQueue { guard dependencyInfo.jobs.count == dependencyInfo.expectedCount else { SNLog("[JobRunner] \(queueContext) found job with missing dependencies, removing the job") - handleJobFailed(nextJob, error: JobRunnerError.missingDependencies, permanentFailure: true) + handleJobFailed( + nextJob, + error: JobRunnerError.missingDependencies, + permanentFailure: true, + dependencies: dependencies + ) return } guard dependencyInfo.jobs.isEmpty else { @@ -792,7 +957,7 @@ private final class JobQueue { ) queue.append(nextJob) } - handleJobDeferred(nextJob) + handleJobDeferred(nextJob, dependencies: dependencies) return } @@ -810,7 +975,7 @@ private final class JobQueue { } } - handleJobDeferred(nextJob) + handleJobDeferred(nextJob, dependencies: dependencies) return } @@ -831,26 +996,45 @@ private final class JobQueue { detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(nextJob.id, nextJob.details) } SNLog("[JobRunner] \(queueContext) started \(nextJob.variant) job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)") + /// As it turns out Combine doesn't plat too nicely with concurrent Dispatch Queues, in Combine events are dispatched asynchronously to + /// the queue which means an odd situation can occasionally occur where the `finished` event can actually run before the `output` + /// event - this can result in unexpected behaviours (for more information see https://github.com/groue/GRDB.swift/issues/1334) + /// + /// Due to this if a job is meant to run on a concurrent queue then we actually want to create a temporary serial queue just for the execution + /// of that job + let targetQueue: DispatchQueue = { + guard executionType == .concurrent else { return internalQueue } + + return DispatchQueue( + label: "\(self.queueContext)-serial", + qos: self.qosClass, + attributes: [], + autoreleaseFrequency: .inherit, + target: nil + ) + }() + jobExecutor.run( nextJob, - queue: internalQueue, + queue: targetQueue, success: handleJobSucceeded, failure: handleJobFailed, - deferred: handleJobDeferred + deferred: handleJobDeferred, + dependencies: dependencies ) // If this queue executes concurrently and there are still jobs remaining then immediately attempt // to start the next job if executionType == .concurrent && numJobsRemaining > 0 { internalQueue.async { [weak self] in - self?.runNextJob() + self?.runNextJob(dependencies: dependencies) } } } - private func scheduleNextSoonestJob() { + private func scheduleNextSoonestJob(dependencies: Dependencies) { let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue - let nextJobTimestamp: TimeInterval? = Storage.shared.read { db in + let nextJobTimestamp: TimeInterval? = dependencies.storage.read { db in try Job .filterPendingJobs( variants: jobVariants, @@ -865,7 +1049,7 @@ private final class JobQueue { // If there are no remaining jobs or the JobRunner isn't allowed to start any queues then trigger // the 'onQueueDrained' callback and stop - guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, JobRunner.canStartQueues.wrappedValue else { + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, JobRunner.canStart(queue: self) else { if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { self.onQueueDrained?() } @@ -889,7 +1073,7 @@ private final class JobQueue { // queue (for concurrent queues we want to force them to load in pending jobs and add // them to the queue regardless of whether the queue is already running) internalQueue.async { [weak self] in - self?.start(force: (self?.executionType == .concurrent)) + self?.start(force: (self?.executionType == .concurrent), dependencies: dependencies) } return } @@ -901,17 +1085,21 @@ private final class JobQueue { SNLog("[JobRunner] Stopping \(queueContext) until next job in \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")") nextTrigger.mutate { trigger in trigger?.invalidate() // Need to invalidate the old trigger to prevent a memory leak - trigger = Trigger.create(queue: self, timestamp: nextJobTimestamp) + trigger = Trigger.create(queue: self, timestamp: nextJobTimestamp, dependencies: dependencies) } } // MARK: - Handling Results /// This function is called when a job succeeds - private func handleJobSucceeded(_ job: Job, shouldStop: Bool) { + private func handleJobSucceeded( + _ job: Job, + shouldStop: Bool, + dependencies: Dependencies + ) { switch job.behaviour { case .runOnce, .runOnceNextLaunch: - Storage.shared.write { db in + dependencies.storage.write { db in // First remove any JobDependencies requiring this job to be completed (if // we don't then the dependant jobs will automatically be deleted) _ = try JobDependencies @@ -922,7 +1110,7 @@ private final class JobQueue { } case .recurring where shouldStop == true: - Storage.shared.write { db in + dependencies.storage.write { db in // First remove any JobDependencies requiring this job to be completed (if // we don't then the dependant jobs will automatically be deleted) _ = try JobDependencies @@ -938,7 +1126,7 @@ private final class JobQueue { case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970: guard let jobId: Int64 = job.id else { break } - Storage.shared.write { db in + dependencies.storage.write { db in _ = try Job .filter(id: jobId) .updateAll( @@ -958,7 +1146,7 @@ private final class JobQueue { job.nextRunTimestamp > TimeInterval.leastNonzeroMagnitude else { break } - Storage.shared.write { db in + dependencies.storage.write { db in _ = try Job .filter(id: jobId) .updateAll( @@ -974,7 +1162,7 @@ private final class JobQueue { // For concurrent queues retrieve any 'dependant' jobs and re-add them here (if they have other // dependencies they will be removed again when they try to execute) if executionType == .concurrent { - let dependantJobs: [Job] = Storage.shared + let dependantJobs: [Job] = dependencies.storage .read { db in try job.dependantJobs.fetchAll(db) } .defaulting(to: []) let dependantJobIds: [Int64] = dependantJobs @@ -997,19 +1185,24 @@ private final class JobQueue { // Perform job cleanup and start the next job performCleanUp(for: job, result: .succeeded) internalQueue.async { [weak self] in - self?.runNextJob() + self?.runNextJob(dependencies: dependencies) } } /// This function is called when a job fails, if it's wasn't a permanent failure then the 'failureCount' for the job will be incremented and it'll /// be re-run after a retry interval has passed - private func handleJobFailed(_ job: Job, error: Error?, permanentFailure: Bool) { + private func handleJobFailed( + _ job: Job, + error: Error?, + permanentFailure: Bool, + dependencies: Dependencies + ) { guard Storage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { SNLog("[JobRunner] \(queueContext) \(job.variant) job canceled") performCleanUp(for: job, result: .failed) internalQueue.async { [weak self] in - self?.runNextJob() + self?.runNextJob(dependencies: dependencies) } return } @@ -1040,13 +1233,13 @@ private final class JobQueue { } internalQueue.async { [weak self] in - self?.runNextJob() + self?.runNextJob(dependencies: dependencies) } return } // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) - let maxFailureCount: Int = (JobRunner.executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) + let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) Storage.shared.write { db in @@ -1116,13 +1309,16 @@ private final class JobQueue { performCleanUp(for: job, result: .failed) internalQueue.async { [weak self] in - self?.runNextJob() + self?.runNextJob(dependencies: dependencies) } } /// This function is called when a job neither succeeds or fails (this should only occur if the job has specific logic that makes it dependant /// on other jobs, and it should automatically manage those dependencies) - private func handleJobDeferred(_ job: Job) { + private func handleJobDeferred( + _ job: Job, + dependencies: Dependencies + ) { var stuckInDeferLoop: Bool = false deferLoopTracker.mutate { @@ -1160,13 +1356,18 @@ private final class JobQueue { // more than 'deferralLoopThreshold' times within 'deferralLoopThreshold' seconds) guard !stuckInDeferLoop else { deferLoopTracker.mutate { $0 = $0.removingValue(forKey: job.id) } - handleJobFailed(job, error: JobRunnerError.possibleDeferralLoop, permanentFailure: false) + handleJobFailed( + job, + error: JobRunnerError.possibleDeferralLoop, + permanentFailure: false, + dependencies: dependencies + ) return } performCleanUp(for: job, result: .deferred) internalQueue.async { [weak self] in - self?.runNextJob() + self?.runNextJob(dependencies: dependencies) } } diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift new file mode 100644 index 000000000..b6dfcc2d7 --- /dev/null +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -0,0 +1,68 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class JobRunnerSpec: QuickSpec { + public enum TestSuccessfulJob: JobExecutor { + static let maxFailureCount: Int = 0 + static let requiresThreadId: Bool = false + static let requiresInteractionId: Bool = false + + static func run( + _ job: Job, + queue: DispatchQueue, + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies + ) { + success(job, true, dependencies) + } + } + + // MARK: - Spec + + override func spec() { + var jobRunner: JobRunner! + var mockStorage: Storage! + var dependencies: Dependencies! + + // MARK: - JobRunner + + describe("a JobRunner") { + beforeEach { + mockStorage = Storage( + customWriter: try! DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations() + ] + ) + dependencies = Dependencies( + storage: mockStorage, + date: Date(timeIntervalSince1970: 1234567890) + ) + + jobRunner = JobRunner() + } + + afterEach { + jobRunner = nil + mockStorage = nil + dependencies = nil + } + + context("when configuring") { + it("adds an executor correctly") { + // TODO: Test this + jobRunner.add(executor: TestSuccessfulJob.self, for: .messageSend) + } + } + } + } +} From ffdc59b7044ac7893f5cd28fac4b0d26db0620d9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 5 Apr 2023 16:49:38 +1000 Subject: [PATCH 02/50] Fixed a few issues with the JobRunner Updated the JobRunner to support dependency injection Updated the DataExtractionNotification to take a 'sentTimestamp' when created to reduce the chance for duplicates being sent Fixed an issue where checking current and pending jobs wasn't including blocking jobs Fixed an issue where the 'hasPendingOrRunningJob' check didn't actually include running jobs Fixed some odd behaviours with job dependencies Fixed an incorrect failure count check --- Podfile.lock | 2 +- Session.xcodeproj/project.pbxproj | 28 + .../ConversationVC+Interaction.swift | 6 +- .../MediaPageViewController.swift | 3 +- Session/Meta/AppDelegate.swift | 2 +- SessionMessagingKit/Configuration.swift | 24 +- .../Jobs/Types/AttachmentDownloadJob.swift | 4 +- .../Jobs/Types/MessageSendJob.swift | 16 +- .../DataExtractionNotification.swift | 9 +- .../Jobs/Types/MessageSendJobSpec.swift | 415 ++++++ .../Open Groups/OpenGroupManagerSpec.swift | 4 +- .../_TestUtilities/TestOnionRequestAPI.swift | 4 +- SessionSnodeKit/Configuration.swift | 2 +- SessionUtilitiesKit/Database/Models/Job.swift | 4 +- .../Database/Models/JobDependencies.swift | 2 +- .../General/Array+Utilities.swift | 8 + .../General/Dependencies.swift | 8 + .../General/Set+Utilities.swift | 6 + SessionUtilitiesKit/JobRunner/JobRunner.swift | 794 ++++++----- .../JobRunner/JobRunnerSpec.swift | 1220 ++++++++++++++++- .../CommonMockedExtensions.swift | 18 + _SharedTestUtilities/MockJobRunner.swift | 56 + 22 files changed, 2238 insertions(+), 397 deletions(-) create mode 100644 SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift create mode 100644 _SharedTestUtilities/MockJobRunner.swift diff --git a/Podfile.lock b/Podfile.lock index 0239f9c29..9cd0a2706 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -242,6 +242,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 2bf7639359fecebe56e9757d88f4eb48864652d2 +PODFILE CHECKSUM: 97324ae5888b01db2f2adc4dcc239e2e7d6867f7 COCOAPODS: 1.11.3 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e8773d8c6..e593f16fd 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -761,6 +761,10 @@ FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; }; FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; }; FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; }; + FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */; }; + FD96F3A729DBD43D00401309 /* MockJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD96F3A629DBD43D00401309 /* MockJobRunner.swift */; }; + FD96F3A829DBD4AD00401309 /* MockJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD96F3A629DBD43D00401309 /* MockJobRunner.swift */; }; + FD96F3A929DBD4AD00401309 /* MockJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD96F3A629DBD43D00401309 /* MockJobRunner.swift */; }; FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */; }; FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */; }; FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */; }; @@ -1841,6 +1845,8 @@ FD87DD0328B8727D00AF0F98 /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSnodePoolJob.swift; sourceTree = ""; }; FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; + FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSendJobSpec.swift; sourceTree = ""; }; + FD96F3A629DBD43D00401309 /* MockJobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockJobRunner.swift; sourceTree = ""; }; FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessageSendsJob.swift; sourceTree = ""; }; FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAttachmentDownloadsJob.swift; sourceTree = ""; }; FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = ""; }; @@ -3928,6 +3934,7 @@ children = ( FDC290A527D860CE005DAE71 /* Mock.swift */, FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, + FD96F3A629DBD43D00401309 /* MockJobRunner.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */, FD078E4727E02561000769AF /* CommonMockedExtensions.swift */, @@ -3970,6 +3977,22 @@ path = JobRunner; sourceTree = ""; }; + FD96F3A229DBC3BA00401309 /* Jobs */ = { + isa = PBXGroup; + children = ( + FD96F3A329DBC3D000401309 /* Types */, + ); + path = Jobs; + sourceTree = ""; + }; + FD96F3A329DBC3D000401309 /* Types */ = { + isa = PBXGroup; + children = ( + FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */, + ); + path = Types; + sourceTree = ""; + }; FDC2909227D710A9005DAE71 /* Types */ = { isa = PBXGroup; children = ( @@ -4059,6 +4082,7 @@ FDC4389B27BA01E300C60D73 /* _TestUtilities */, FD3C905D27E410DB00CD579F /* Common Networking */, FD3C906527E416A200CD579F /* Contacts */, + FD96F3A229DBC3BA00401309 /* Jobs */, FDC4389827BA001800C60D73 /* Open Groups */, FD3C906B27E43C2400CD579F /* Sending & Receiving */, FD3C906827E417B100CD579F /* Utilities */, @@ -5794,6 +5818,7 @@ FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */, FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */, FD23EA5F28ED00FF0058676E /* CommonMockedExtensions.swift in Sources */, + FD96F3A829DBD4AD00401309 /* MockJobRunner.swift in Sources */, FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */, FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */, FD23EA5C28ED00F80058676E /* Mock.swift in Sources */, @@ -5807,6 +5832,7 @@ buildActionMask = 2147483647; files = ( FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */, + FD96F3A929DBD4AD00401309 /* MockJobRunner.swift in Sources */, FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */, FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, @@ -5827,9 +5853,11 @@ FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, + FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */, FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */, + FD96F3A729DBD43D00401309 /* MockJobRunner.swift in Sources */, FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 037e39647..8f533f286 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -2015,7 +2015,8 @@ extension ConversationVC: try MessageSender.send( db, message: DataExtractionNotification( - kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)) + kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)), + sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) ), interactionId: nil, in: thread @@ -2269,7 +2270,8 @@ extension ConversationVC: try MessageSender.send( db, message: DataExtractionNotification( - kind: .screenshot + kind: .screenshot, + sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) ), interactionId: nil, in: thread diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 49d9374e5..0ffc3cd42 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -540,7 +540,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou message: DataExtractionNotification( kind: .mediaSaved( timestamp: UInt64(currentViewController.galleryItem.interactionTimestampMs) - ) + ), + sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) ), interactionId: nil, // Show no interaction for the current user in: thread diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index d91851597..dc7d58d64 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -254,7 +254,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD private func completePostMigrationSetup(needsConfigSync: Bool) { Configuration.performMainSetup() - JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens) + JobRunner.setExecutor(SyncPushTokensJob.self, for: .syncPushTokens) /// Setup the UI /// diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index b50ac3b94..2edd387b3 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -34,17 +34,17 @@ public enum SNMessagingKit { // Just to make the external API nice public static func configure() { // Configure the job executors - JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages) - JobRunner.add(executor: FailedMessageSendsJob.self, for: .failedMessageSends) - JobRunner.add(executor: FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads) - JobRunner.add(executor: UpdateProfilePictureJob.self, for: .updateProfilePicture) - JobRunner.add(executor: RetrieveDefaultOpenGroupRoomsJob.self, for: .retrieveDefaultOpenGroupRooms) - JobRunner.add(executor: GarbageCollectionJob.self, for: .garbageCollection) - JobRunner.add(executor: MessageSendJob.self, for: .messageSend) - JobRunner.add(executor: MessageReceiveJob.self, for: .messageReceive) - JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer) - JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts) - JobRunner.add(executor: AttachmentDownloadJob.self, for: .attachmentDownload) - JobRunner.add(executor: AttachmentUploadJob.self, for: .attachmentUpload) + JobRunner.setExecutor(DisappearingMessagesJob.self, for: .disappearingMessages) + JobRunner.setExecutor(FailedMessageSendsJob.self, for: .failedMessageSends) + JobRunner.setExecutor(FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads) + JobRunner.setExecutor(UpdateProfilePictureJob.self, for: .updateProfilePicture) + JobRunner.setExecutor(RetrieveDefaultOpenGroupRoomsJob.self, for: .retrieveDefaultOpenGroupRooms) + JobRunner.setExecutor(GarbageCollectionJob.self, for: .garbageCollection) + JobRunner.setExecutor(MessageSendJob.self, for: .messageSend) + JobRunner.setExecutor(MessageReceiveJob.self, for: .messageReceive) + JobRunner.setExecutor(NotifyPushServerJob.self, for: .notifyPushServer) + JobRunner.setExecutor(SendReadReceiptsJob.self, for: .sendReadReceipts) + JobRunner.setExecutor(AttachmentDownloadJob.self, for: .attachmentDownload) + JobRunner.setExecutor(AttachmentUploadJob.self, for: .attachmentUpload) } } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 14934a4e7..4eeb8e27c 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -42,8 +42,8 @@ public enum AttachmentDownloadJob: JobExecutor { // the same attachment multiple times at the same time (it also adds a "clean up" mechanism // if an attachment ends up stuck in a "downloading" state incorrectly guard attachment.state != .downloading else { - let otherCurrentJobAttachmentIds: Set = JobRunner - .detailsForCurrentlyRunningJobs(of: .attachmentDownload) + let otherCurrentJobAttachmentIds: Set = dependencies.jobRunner + .detailsFor(state: .running, variant: .attachmentDownload) .filter { key, _ in key != job.id } .values .compactMap { data -> String? in diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 050dc1bfa..ef9ae90d4 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -24,7 +24,7 @@ public enum MessageSendJob: JobExecutor { let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, false, dependencies) + failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) return } @@ -37,7 +37,7 @@ public enum MessageSendJob: JobExecutor { let jobId: Int64 = job.id, let interactionId: Int64 = job.interactionId else { - failure(job, JobRunnerError.missingRequiredDetails, false, dependencies) + failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) return } @@ -89,16 +89,16 @@ public enum MessageSendJob: JobExecutor { } .filter { stateInfo in // Don't add a new job if there is one already in the queue - !JobRunner.hasPendingOrRunningJob( - with: .attachmentUpload, - details: AttachmentUploadJob.Details( + !dependencies.jobRunner.hasJob( + of: .attachmentUpload, + with: AttachmentUploadJob.Details( messageSendJobId: jobId, attachmentId: stateInfo.attachmentId ) ) } .compactMap { stateInfo -> (jobId: Int64, job: Job)? in - JobRunner + dependencies.jobRunner .insert( db, job: Job( @@ -131,8 +131,8 @@ public enum MessageSendJob: JobExecutor { let hasPendingUploads: Bool = allAttachmentStateInfo.contains(where: { $0.state != .uploaded }) return ( - (isMissingFileIds && !hasPendingUploads), - hasPendingUploads, + (isMissingFileIds && !hasPendingUploads), // shouldFail + hasPendingUploads, // shouldDefer fileIds ) } diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index d54549df1..627d58ea8 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -27,8 +27,13 @@ public final class DataExtractionNotification: ControlMessage { // MARK: - Initialization - public init(kind: Kind) { - super.init() + public init( + kind: Kind, + sentTimestamp: UInt64? = nil + ) { + super.init( + sentTimestamp: sentTimestamp + ) self.kind = kind } diff --git a/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift new file mode 100644 index 000000000..f828c4394 --- /dev/null +++ b/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift @@ -0,0 +1,415 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +import Quick +import Nimble + +@testable import SessionMessagingKit +@testable import SessionUtilitiesKit + +class MessageSendJobSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var job: Job! + var interaction: Interaction! + var attachment1: Attachment! + var interactionAttachment1: InteractionAttachment! + var mockStorage: Storage! + var mockJobRunner: MockJobRunner! + var dependencies: Dependencies! + + // MARK: - JobRunner + + describe("a MessageSendJob") { + beforeEach { + mockStorage = Storage( + customWriter: try! DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNMessagingKit.migrations() + ] + ) + mockJobRunner = MockJobRunner() + dependencies = Dependencies( + storage: mockStorage, + jobRunner: mockJobRunner, + date: Date(timeIntervalSince1970: 1234567890) + ) + attachment1 = Attachment( + id: "200", + variant: .standard, + state: .failedDownload, + contentType: "text/plain", + byteCount: 200 + ) + + mockStorage.write { db in + try SessionThread.fetchOrCreate(db, id: "Test1", variant: .contact) + } + + mockJobRunner + .when { + $0.hasJob( + of: any(), + inState: .running, + with: AttachmentUploadJob.Details( + messageSendJobId: 1, + attachmentId: attachment1.id + ) + ) + } + .thenReturn(false) + mockJobRunner + .when { $0.insert(any(), job: any(), before: any(), dependencies: dependencies) } + .then { args in + let db: Database = args[0] as! Database + var job: Job = args[1] as! Job + job.id = 1000 + + try! job.insert(db) + } + .thenReturn((1000, Job(variant: .messageSend))) + } + + afterEach { + job = nil + mockStorage = nil + dependencies = nil + } + + it("fails when not given any details") { + job = Job(variant: .messageSend) + + var error: Error? = nil + var permanentFailure: Bool = false + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, runError, runPermanentFailure, _ in + error = runError + permanentFailure = runPermanentFailure + }, + deferred: { _, _ in }, + dependencies: dependencies + ) + + expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + expect(permanentFailure).to(beTrue()) + } + + it("fails when not given incorrect details") { + job = Job( + variant: .messageSend, + details: MessageReceiveJob.Details(messages: [], calledFromBackgroundPoller: false) + ) + + var error: Error? = nil + var permanentFailure: Bool = false + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, runError, runPermanentFailure, _ in + error = runError + permanentFailure = runPermanentFailure + }, + deferred: { _, _ in }, + dependencies: dependencies + ) + + expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + expect(permanentFailure).to(beTrue()) + } + + context("of VisibleMessage") { + beforeEach { + interaction = Interaction( + id: 100, + serverHash: nil, + messageUuid: nil, + threadId: "Test1", + authorId: "Test", + variant: .standardOutgoing, + body: "Test", + timestampMs: 1234567890, + receivedAtTimestampMs: 1234567900, + wasRead: false, + hasMention: false, + expiresInSeconds: nil, + expiresStartedAtMs: nil, + linkPreviewUrl: nil, + openGroupServerMessageId: nil, + openGroupWhisperMods: false, + openGroupWhisperTo: nil + ) + job = Job( + variant: .messageSend, + interactionId: interaction.id!, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + + mockStorage.write { db in + try interaction.insert(db) + try job.insert(db) + } + } + + it("fails when there is no job id") { + job = Job( + variant: .messageSend, + interactionId: interaction.id!, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + + var error: Error? = nil + var permanentFailure: Bool = false + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, runError, runPermanentFailure, _ in + error = runError + permanentFailure = runPermanentFailure + }, + deferred: { _, _ in }, + dependencies: dependencies + ) + + expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + expect(permanentFailure).to(beTrue()) + } + + it("fails when there is no interaction id") { + job = Job( + variant: .messageSend, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + + var error: Error? = nil + var permanentFailure: Bool = false + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, runError, runPermanentFailure, _ in + error = runError + permanentFailure = runPermanentFailure + }, + deferred: { _, _ in }, + dependencies: dependencies + ) + + expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + expect(permanentFailure).to(beTrue()) + } + + it("fails when there is no interaction for the provided interaction id") { + job = Job( + variant: .messageSend, + interactionId: 12345, + details: MessageSendJob.Details( + destination: .contact(publicKey: "Test"), + message: VisibleMessage( + text: "Test" + ) + ) + ) + mockStorage.write { db in try job.insert(db) } + + var error: Error? = nil + var permanentFailure: Bool = false + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, runError, runPermanentFailure, _ in + error = runError + permanentFailure = runPermanentFailure + }, + deferred: { _, _ in }, + dependencies: dependencies + ) + + expect(error).to(matchError(StorageError.objectNotFound)) + expect(permanentFailure).to(beTrue()) + } + context("with an attachment") { + beforeEach { + interactionAttachment1 = InteractionAttachment( + albumIndex: 0, + interactionId: interaction.id!, + attachmentId: attachment1.id + ) + + mockStorage.write { db in + try attachment1.insert(db) + try interactionAttachment1.insert(db) + } + } + + it("it fails when trying to send with an attachment which previously failed to download") { + mockStorage.write { db in + try attachment1.with(state: .failedDownload).save(db) + } + + var error: Error? = nil + var permanentFailure: Bool = false + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, runError, runPermanentFailure, _ in + error = runError + permanentFailure = runPermanentFailure + }, + deferred: { _, _ in }, + dependencies: dependencies + ) + + expect(error).to(matchError(AttachmentError.notUploaded)) + expect(permanentFailure).to(beTrue()) + } + + it("it fails when trying to send with an attachment that has an invalid downloadUrl") { + mockStorage.write { db in + try attachment1 + .with( + state: .uploaded, + downloadUrl: nil + ) + .save(db) + } + + var error: Error? = nil + var permanentFailure: Bool = false + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, runError, runPermanentFailure, _ in + error = runError + permanentFailure = runPermanentFailure + }, + deferred: { _, _ in }, + dependencies: dependencies + ) + + expect(error).to(matchError(AttachmentError.notUploaded)) + expect(permanentFailure).to(beTrue()) + } + + context("with a pending upload") { + beforeEach { + mockStorage.write { db in + try attachment1.with(state: .uploading).save(db) + } + } + + it("it defers when trying to send with an attachment which is still pending upload") { + var didDefer: Bool = false + + mockStorage.write { db in + try attachment1.with(state: .uploading).save(db) + } + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in didDefer = true }, + dependencies: dependencies + ) + + expect(didDefer).to(beTrue()) + } + + it("inserts an attachment upload job before the message send job") { + mockJobRunner + .when { + $0.hasJob( + of: any(), + inState: .running, + with: AttachmentUploadJob.Details( + messageSendJobId: 1, + attachmentId: "200" + ) + ) + } + .thenReturn(false) + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in }, + dependencies: dependencies + ) + + expect(mockJobRunner) + .to(call(.exactly(times: 1), matchingParameters: true) { + $0.insert( + any(), + job: Job( + variant: .attachmentUpload, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + interactionId: 100, + details: AttachmentUploadJob.Details( + messageSendJobId: 1, + attachmentId: "200" + ) + ), + before: job, + dependencies: dependencies + ) + }) + } + + it("creates a dependency between the new job and the existing one") { + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in }, + dependencies: dependencies + ) + + expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) + .to(equal(JobDependencies(jobId: 9, dependantId: 1000))) + } + } + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 03618a3b5..a8b3743e0 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -3568,11 +3568,11 @@ class OpenGroupManagerSpec: QuickSpec { it("adds the image retrieval promise to the cache") { class TestNeverReturningApi: OnionRequestAPIType { - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String, timeout: TimeInterval) -> Promise<(OnionRequestResponseInfoType, Data?)> { return Promise<(OnionRequestResponseInfoType, Data?)>.pending().promise } - static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise { + static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?, timeout: TimeInterval) -> Promise { return Promise.value(Data()) } } diff --git a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift index 67b7dde86..3da2fbdd4 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift @@ -34,7 +34,7 @@ class TestOnionRequestAPI: OnionRequestAPIType { class var mockResponse: Data? { return nil } - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String, timeout: TimeInterval) -> Promise<(OnionRequestResponseInfoType, Data?)> { let responseInfo: ResponseInfo = ResponseInfo( requestData: RequestData( urlString: request.url?.absoluteString, @@ -54,7 +54,7 @@ class TestOnionRequestAPI: OnionRequestAPIType { return Promise.value((responseInfo, mockResponse)) } - static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise { + static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?, timeout: TimeInterval) -> Promise { return Promise.value(mockResponse!) } } diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index 259ac87ab..5035bfa78 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -24,6 +24,6 @@ public enum SNSnodeKit { // Just to make the external API nice public static func configure() { // Configure the job executors - JobRunner.add(executor: GetSnodePoolJob.self, for: .getSnodePool) + JobRunner.setExecutor(GetSnodePoolJob.self, for: .getSnodePool) } } diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 037d83fc3..b2f7dc8fd 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -3,7 +3,7 @@ import Foundation import GRDB -public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { +public struct Job: Codable, Hashable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "job" } internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId]) public static let dependantJobDependency = hasMany( @@ -184,7 +184,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer // MARK: - Initialization - fileprivate init( + internal init( id: Int64?, failureCount: UInt, variant: Variant, diff --git a/SessionUtilitiesKit/Database/Models/JobDependencies.swift b/SessionUtilitiesKit/Database/Models/JobDependencies.swift index 9cda7ceb1..3613d6912 100644 --- a/SessionUtilitiesKit/Database/Models/JobDependencies.swift +++ b/SessionUtilitiesKit/Database/Models/JobDependencies.swift @@ -3,7 +3,7 @@ import Foundation import GRDB -public struct JobDependencies: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct JobDependencies: Codable, Hashable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "jobDependencies" } internal static let jobForeignKey = ForeignKey([Columns.jobId], to: [Job.Columns.id]) internal static let dependantForeignKey = ForeignKey([Columns.dependantId], to: [Job.Columns.id]) diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index cf350e86d..77445b64b 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -45,6 +45,14 @@ public extension Array { return updatedArray } + func inserting(contentsOf other: [Element]?, at index: Int) -> [Element] { + guard let other: [Element] = other else { return self } + + var updatedArray: [Element] = self + updatedArray.insert(contentsOf: other, at: 0) + return updatedArray + } + func grouped(by keyForValue: (Element) throws -> Key) -> [Key: [Element]] { return ((try? Dictionary(grouping: self, by: keyForValue)) ?? [:]) } diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift index e9c576639..61e2f510d 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -16,6 +16,12 @@ open class Dependencies { set { _storage.mutate { $0 = newValue } } } + public var _jobRunner: Atomic + public var jobRunner: JobRunnerType { + get { Dependencies.getValueSettingIfNull(&_jobRunner) { JobRunner.instance } } + set { _jobRunner.mutate { $0 = newValue } } + } + public var _scheduler: Atomic public var scheduler: ValueObservationScheduler { get { Dependencies.getValueSettingIfNull(&_scheduler) { Storage.defaultPublisherScheduler } } @@ -39,12 +45,14 @@ open class Dependencies { public init( generalCache: Atomic? = nil, storage: Storage? = nil, + jobRunner: JobRunnerType? = nil, scheduler: ValueObservationScheduler? = nil, standardUserDefaults: UserDefaultsType? = nil, date: Date? = nil ) { _generalCache = Atomic(generalCache) _storage = Atomic(storage) + _jobRunner = Atomic(jobRunner) _scheduler = Atomic(scheduler) _standardUserDefaults = Atomic(standardUserDefaults) _date = Atomic(date) diff --git a/SessionUtilitiesKit/General/Set+Utilities.swift b/SessionUtilitiesKit/General/Set+Utilities.swift index 5fb2d416b..f6f45d27a 100644 --- a/SessionUtilitiesKit/General/Set+Utilities.swift +++ b/SessionUtilitiesKit/General/Set+Utilities.swift @@ -3,6 +3,12 @@ import Foundation public extension Set { + mutating func insert(contentsOf value: Set?) { + guard let value: Set = value else { return } + + value.forEach { self.insert($0) } + } + func inserting(_ value: Element?) -> Set { guard let value: Element = value else { return self } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index fafb086d2..484a36aa7 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -3,6 +3,62 @@ import Foundation import GRDB +public protocol JobRunnerType { + // MARK: - Configuration + + func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) + func canStart(queue: JobQueue) -> Bool + + // MARK: - State Management + + func isCurrentlyRunning(_ job: Job?) -> Bool + func hasJob(of variant: Job.Variant, inState state: JobRunner.JobState, with jobDetails: T) -> Bool + func detailsFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: Data?] + + func appDidFinishLaunching(dependencies: Dependencies) + func appDidBecomeActive(dependencies: Dependencies) + func startNonBlockingQueues(dependencies: Dependencies) + func stopAndClearPendingJobs(exceptForVariant: Job.Variant?, onComplete: (() -> ())?) + + // MARK: - Job Scheduling + + func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) + func upsert(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) + @discardableResult func insert(_ db: Database, job: Job?, before otherJob: Job, dependencies: Dependencies) -> (Int64, Job)? +} + +public extension JobRunnerType { + func stopAndClearPendingJobs(exceptForVariant: Job.Variant? = nil, onComplete: (() -> ())? = nil) { + stopAndClearPendingJobs(exceptForVariant: exceptForVariant, onComplete: onComplete) + } + + func hasJob(of variant: Job.Variant, inState state: JobRunner.JobState = .any, with jobDetails: T) -> Bool { + return hasJob(of: variant, inState: state, with: jobDetails) + } + + func details() -> [Int64: Data?] { return detailsFor(jobs: nil, state: .any, variant: nil) } + + func detailsFor(jobs: [Job]) -> [Int64: Data?] { + return detailsFor(jobs: jobs, state: .any, variant: nil) + } + + func detailsFor(jobs: [Job], state: JobRunner.JobState) -> [Int64: Data?] { + return detailsFor(jobs: jobs, state: state, variant: nil) + } + + func detailsFor(state: JobRunner.JobState) -> [Int64: Data?] { + return detailsFor(jobs: nil, state: state, variant: nil) + } + + func detailsFor(state: JobRunner.JobState, variant: Job.Variant) -> [Int64: Data?] { + return detailsFor(jobs: nil, state: state, variant: variant) + } + + func detailsFor(variant: Job.Variant) -> [Int64: Data?] { + return detailsFor(jobs: nil, state: .any, variant: variant) + } +} + public protocol JobExecutor { /// The maximum number of times the job can fail before it fails permanently /// @@ -35,7 +91,20 @@ public protocol JobExecutor { ) } -public final class JobRunner { +public final class JobRunner: JobRunnerType { + public struct JobState: OptionSet, Hashable { + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + + public static let pending: JobState = JobState(rawValue: 1 << 0) + public static let running: JobState = JobState(rawValue: 1 << 1) + + public static let any: JobState = [ .pending, .running ] + } + public enum JobResult { case succeeded case failed @@ -45,19 +114,33 @@ public final class JobRunner { // MARK: - Variables + private let allowToExecuteJobs: Bool private let blockingQueue: Atomic private let queues: Atomic<[Job.Variant: JobQueue]> + internal var appReadyToStartQueues: Atomic = Atomic(false) internal var perSessionJobsCompleted: Atomic> = Atomic([]) internal var hasCompletedInitialBecomeActive: Atomic = Atomic(false) internal var shutdownBackgroundTask: Atomic = Atomic(nil) - internal var canStartQueues: Atomic = Atomic(false) // MARK: - Initialization - init(dependencies: Dependencies = Dependencies()) { - var jobVariants: Set = Job.Variant.allCases.asSet() + init( + isTestingJobRunner: Bool = false, + variantsToExclude: [Job.Variant] = [], + dependencies: Dependencies = Dependencies() + ) { + var jobVariants: Set = Job.Variant.allCases + .filter { !variantsToExclude.contains($0) } + .asSet() + self.allowToExecuteJobs = ( + isTestingJobRunner || ( + HasAppContext() && + CurrentAppContext().isMainApp && + !CurrentAppContext().isRunningTests + ) + ) self.blockingQueue = Atomic( JobQueue( type: .blocking, @@ -66,7 +149,7 @@ public final class JobRunner { onQueueDrained: { // Once all blocking jobs have been completed we want to start running // the remaining job queues - JobRunner.startNonBlockingQueues(dependencies: dependencies) + dependencies.jobRunner.startNonBlockingQueues(dependencies: dependencies) } ) ) @@ -127,15 +210,92 @@ public final class JobRunner { // MARK: - Configuration - internal func add(executor: JobExecutor.Type, for variant: Job.Variant) { - queues.wrappedValue[variant]?.addExecutor(executor, for: variant) + public func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { + blockingQueue.wrappedValue?.setExecutor(executor, for: variant) // The blocking queue can run any job + queues.wrappedValue[variant]?.setExecutor(executor, for: variant) } - // MARK: - Execution + public func canStart(queue: JobQueue) -> Bool { + return ( + allowToExecuteJobs && + appReadyToStartQueues.wrappedValue + ) + } - internal func appDidFinishLaunching(dependencies: Dependencies) { + // MARK: - State Management + + public func isCurrentlyRunning(_ job: Job?) -> Bool { + guard let job: Job = job else { return false } + + return !detailsFor(jobs: [job], state: .running).isEmpty + } + + public func hasJob(of variant: Job.Variant, inState state: JobRunner.JobState, with jobDetails: T) -> Bool { + guard let detailsData: Data = try? JSONEncoder().encode(jobDetails) else { return false } + + return detailsFor(state: state, variant: variant).values.contains(detailsData) + } + + public func detailsFor( + jobs: [Job]?, + state: JobRunner.JobState, + variant: Job.Variant? + ) -> [Int64: Data?] { + var result: [(Int64, Data?)] = [] + let targetKeys: [JobQueue.JobKey] = (jobs?.compactMap { JobQueue.JobKey($0) } ?? []) + let targetVariants: [Job.Variant] = (variant.map { [$0] } ?? jobs?.map { $0.variant }) + .defaulting(to: []) + + // Insert the state of any pending jobs + if state.contains(.pending) { + func detailsFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, Data?)] { + return (queue?.pendingJobsQueue.wrappedValue + .filter { variants.isEmpty || variants.contains($0.variant) } + .compactMap { job -> (Int64, Data?)? in + guard let jobKey: JobQueue.JobKey = JobQueue.JobKey(job) else { return nil } + guard !targetKeys.isEmpty else { return (jobKey.id, job.details) } + + return (targetKeys.contains(jobKey) ? (jobKey.id, job.details) : nil) + }) + .defaulting(to: []) + } + + result.append(contentsOf: detailsFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) + queues.wrappedValue + .filter { key, _ -> Bool in targetVariants.isEmpty || targetVariants.contains(key) } + .values + .forEach { queue in result.append(contentsOf: detailsFor(queue: queue, variants: targetVariants)) } + } + + // Insert the state of any running jobs + if state.contains(.running) { + func detailsFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, Data?)] { + return (queue?.detailsForCurrentlyRunningJobs.wrappedValue + .filter { variants.isEmpty || variants.contains($0.key.variant) } + .compactMap { jobKey, details -> (Int64, Data?)? in + guard !targetKeys.isEmpty else { return (jobKey.id, details) } + + return (targetKeys.contains(jobKey) ? (jobKey.id, details) : nil) + }) + .defaulting(to: []) + } + + result.append(contentsOf: detailsFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) + queues.wrappedValue + .filter { key, _ -> Bool in targetVariants.isEmpty || targetVariants.contains(key) } + .values + .forEach { queue in result.append(contentsOf: detailsFor(queue: queue, variants: targetVariants)) } + } + + return result + .reduce(into: [:]) { result, next in + result[next.0] = next.1 + } + } + + public func appDidFinishLaunching(dependencies: Dependencies) { // Flag that the JobRunner can start it's queues - canStartQueues.mutate { $0 = true } + appReadyToStartQueues.mutate { $0 = true } // Note: 'appDidBecomeActive' will run on first launch anyway so we can // leave those jobs out and can wait until then to start the JobRunner @@ -185,9 +345,9 @@ public final class JobRunner { } } - internal func appDidBecomeActive(dependencies: Dependencies) { + public func appDidBecomeActive(dependencies: Dependencies) { // Flag that the JobRunner can start it's queues - canStartQueues.mutate { $0 = true } + appReadyToStartQueues.mutate { $0 = true } // If we have a running "sutdownBackgroundTask" then we want to cancel it as otherwise it // can result in the database being suspended and us being unable to interact with it at all @@ -232,97 +392,20 @@ public final class JobRunner { self.hasCompletedInitialBecomeActive.mutate { $0 = true } } - internal func add( - _ db: Database, - job: Job?, - canStartJob: Bool, - dependencies: Dependencies - ) { - // 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 - } - guard !canStartJob || updatedJob.id != nil else { - SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") - return - } - - queues.mutate { - $0[updatedJob.variant]? - .add(updatedJob, canStartJob: canStartJob, dependencies: 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: \(updatedJob.variant)") { [weak self] _ in - self?.queues.wrappedValue[updatedJob.variant]?.start(dependencies: dependencies) + public func startNonBlockingQueues(dependencies: Dependencies) { + queues.wrappedValue.forEach { _, queue in + queue.start(dependencies: dependencies) } } - internal func upsert( - _ db: Database, - job: Job?, - canStartJob: Bool, - dependencies: Dependencies - ) { - guard let job: Job = job else { return } // Ignore null jobs - guard job.id != nil else { - add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) - return - } - - queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob, dependencies: 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(dependencies: dependencies) - } - } - - @discardableResult internal func insert( - _ db: Database, - job: Job?, - before otherJob: Job, - dependencies: Dependencies - ) -> (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")") - 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 - } - - queues.wrappedValue[updatedJob.variant]? - .insert(updatedJob, before: otherJob, dependencies: dependencies) - - return (jobId, updatedJob) - } - - internal func stopAndClearPendingJobs( - exceptForVariant: Job.Variant? = nil, - onComplete: (() -> ())? = nil + public func stopAndClearPendingJobs( + exceptForVariant: Job.Variant?, + onComplete: (() -> ())? ) { // Inform the JobRunner that it can't start any queues (this is to prevent queues from // rescheduling themselves while in the background, when the app restarts or becomes active // the JobRunenr will update this flag) - canStartQueues.mutate { $0 = false } + appReadyToStartQueues.mutate { $0 = false } // Stop all queues except for the one containing the `exceptForVariant` queues.wrappedValue @@ -373,15 +456,89 @@ public final class JobRunner { } } - internal func isCurrentlyRunning(_ job: Job?) -> Bool { - guard let job: Job = job, let jobId: Int64 = job.id else { return false } + // MARK: - Execution + + public func add( + _ db: Database, + job: Job?, + canStartJob: Bool, + dependencies: Dependencies + ) { + // 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 + } + guard !canStartJob || updatedJob.id != nil else { + SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") + return + } - return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true) + queues.mutate { + $0[updatedJob.variant]? + .add(updatedJob, canStartJob: canStartJob, dependencies: 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: \(updatedJob.variant)") { [weak self] _ in + self?.queues.wrappedValue[updatedJob.variant]?.start(dependencies: dependencies) + } } - internal func detailsForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: Data?] { - return (queues.wrappedValue[variant]?.detailsForAllCurrentlyRunningJobs()) - .defaulting(to: [:]) + public func upsert( + _ db: Database, + job: Job?, + canStartJob: Bool, + dependencies: Dependencies + ) { + guard let job: Job = job else { return } // Ignore null jobs + guard job.id != nil else { + add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) + return + } + + queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob, dependencies: 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(dependencies: dependencies) + } + } + + @discardableResult public func insert( + _ db: Database, + job: Job?, + before otherJob: Job, + dependencies: Dependencies + ) -> (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")") + 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 + } + + queues.wrappedValue[updatedJob.variant]? + .insert(updatedJob, before: otherJob, dependencies: dependencies) + + return (jobId, updatedJob) } internal func afterCurrentlyRunningJob(_ job: Job?, callback: @escaping (JobResult) -> ()) { @@ -393,13 +550,6 @@ public final class JobRunner { queue.afterCurrentlyRunningJob(jobId, callback: callback) } - internal func hasPendingOrRunningJob(with variant: Job.Variant, details: T) -> Bool { - guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false } - guard let detailsData: Data = try? JSONEncoder().encode(details) else { return false } - - return targetQueue.hasPendingOrRunningJob(with: detailsData) - } - internal func removePendingJob(_ job: Job?) { guard let job: Job = job, let jobId: Int64 = job.id else { return } @@ -421,100 +571,9 @@ public final class JobRunner { } } -// MARK: - JobRunner Singleton - -public extension JobRunner { - private static let instance: JobRunner = JobRunner() - - // MARK: - Static Access - - static func add(executor: JobExecutor.Type, for variant: Job.Variant) { - instance.add(executor: executor, for: variant) - } - - static func appDidFinishLaunching(dependencies: Dependencies = Dependencies()) { - instance.appDidFinishLaunching(dependencies: dependencies) - } - - static func appDidBecomeActive(dependencies: Dependencies = Dependencies()) { - instance.appDidBecomeActive(dependencies: dependencies) - } - - /// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start - /// the JobRunner - /// - /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` - /// is in the future then the job won't be started - static func add( - _ db: Database, - job: Job?, - canStartJob: Bool = true, - dependencies: Dependencies = Dependencies() - ) { instance.add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } - - /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start - /// the JobRunner - /// - /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` - /// is in the future then the job won't be started - static func upsert( - _ db: Database, - job: Job?, - canStartJob: Bool = true, - dependencies: Dependencies = Dependencies() - ) { instance.upsert(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } - - @discardableResult static func insert( - _ db: Database, - job: Job?, - before otherJob: Job, - dependencies: Dependencies = Dependencies() - ) -> (Int64, Job)? { instance.insert(db, job: job, before: otherJob, dependencies: dependencies) } - - /// Calling this will clear the JobRunner queues and stop it from running new jobs, any currently executing jobs will continue to run - /// though (this means if we suspend the database it's likely that any currently running jobs will fail to complete and fail to record their - /// failure - they _should_ be picked up again the next time the app is launched) - static func stopAndClearPendingJobs( - exceptForVariant: Job.Variant? = nil, - onComplete: (() -> ())? = nil - ) { instance.stopAndClearPendingJobs(exceptForVariant: exceptForVariant, onComplete: onComplete) } - - static func isCurrentlyRunning(_ job: Job?) -> Bool { - return instance.isCurrentlyRunning(job) - } - - static func detailsForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: Data?] { - return instance.detailsForCurrentlyRunningJobs(of: variant) - } - - static func afterCurrentlyRunningJob(_ job: Job?, callback: @escaping (JobResult) -> ()) { - instance.afterCurrentlyRunningJob(job, callback: callback) - } - - static func hasPendingOrRunningJob(with variant: Job.Variant, details: T) -> Bool { - return instance.hasPendingOrRunningJob(with: variant, details: details) - } - - static func removePendingJob(_ job: Job?) { - instance.removePendingJob(job) - } - - // MARK: - Internal Static Access - - fileprivate static func canStart(queue: JobQueue) -> Bool { - return instance.canStartQueues.wrappedValue - } - - fileprivate static func startNonBlockingQueues(dependencies: Dependencies) { - instance.queues.wrappedValue.forEach { _, queue in - queue.start(dependencies: dependencies) - } - } -} - // MARK: - JobQueue -private final class JobQueue { +public final class JobQueue { fileprivate enum QueueType: Hashable { case blocking case general(number: Int) @@ -577,6 +636,18 @@ private final class JobQueue { } } + fileprivate struct JobKey: Equatable, Hashable { + fileprivate let id: Int64 + fileprivate let variant: Job.Variant + + fileprivate init?(_ job: Job?) { + guard let id: Int64 = job?.id, let variant: Job.Variant = job?.variant else { return nil } + + self.id = id + self.variant = variant + } + } + private static let deferralLoopThreshold: Int = 3 private let type: QueueType @@ -606,17 +677,17 @@ private final class JobQueue { private var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) private var nextTrigger: Atomic = Atomic(nil) fileprivate var isRunning: Atomic = Atomic(false) - private var queue: Atomic<[Job]> = Atomic([]) - private var jobsCurrentlyRunning: Atomic> = Atomic([]) + fileprivate var pendingJobsQueue: Atomic<[Job]> = Atomic([]) + fileprivate var jobsCurrentlyRunning: Atomic> = Atomic([]) + fileprivate var detailsForCurrentlyRunningJobs: Atomic<[JobKey: Data?]> = Atomic([:]) private var jobCallbacks: Atomic<[Int64: [(JobRunner.JobResult) -> ()]]> = Atomic([:]) - private var detailsForCurrentlyRunningJobs: Atomic<[Int64: Data?]> = Atomic([:]) private var deferLoopTracker: Atomic<[Int64: (count: Int, times: [TimeInterval])]> = Atomic([:]) - fileprivate var hasPendingJobs: Bool { !queue.wrappedValue.isEmpty } + fileprivate var hasPendingJobs: Bool { !pendingJobsQueue.wrappedValue.isEmpty } // MARK: - Initialization - init( + fileprivate init( type: QueueType, executionType: ExecutionType = .serial, qos: DispatchQoS, @@ -633,7 +704,7 @@ private final class JobQueue { // MARK: - Configuration - fileprivate func addExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { + fileprivate func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { executorMap.mutate { $0[variant] = executor } } @@ -651,7 +722,7 @@ private final class JobQueue { return } - queue.mutate { $0.append(job) } + pendingJobsQueue.mutate { $0.append(job) } } /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start @@ -665,21 +736,21 @@ private final class JobQueue { return } - // Lock the queue while checking the index and inserting to ensure we don't run into + // Lock the pendingJobsQueue while checking the index and inserting to ensure we don't run into // any multi-threading shenanigans // - // Note: currently running jobs are removed from the queue so we don't need to check + // Note: currently running jobs are removed from the pendingJobsQueue so we don't need to check // the 'jobsCurrentlyRunning' set var didUpdateExistingJob: Bool = false - queue.mutate { queue in + pendingJobsQueue.mutate { queue in if let jobIndex: Array.Index = queue.firstIndex(where: { $0.id == jobId }) { queue[jobIndex] = job didUpdateExistingJob = true } } - // If we didn't update an existing job then we need to add it to the queue + // If we didn't update an existing job then we need to add it to the pendingJobsQueue guard !didUpdateExistingJob else { return } add(job, canStartJob: canStartJob, dependencies: dependencies) @@ -692,10 +763,10 @@ private final class JobQueue { } // Insert the job before the current job (re-adding the current job to - // the start of the queue if it's not in there) - this will mean the new + // the start of the pendingJobsQueue if it's not in there) - this will mean the new // job will run and then the otherJob will run (or run again) once it's // done - queue.mutate { + pendingJobsQueue.mutate { guard let otherJobIndex: Int = $0.firstIndex(of: otherJob) else { $0.insert(contentsOf: [job, otherJob], at: 0) return @@ -710,7 +781,7 @@ private final class JobQueue { canStart: Bool, dependencies: Dependencies ) { - queue.mutate { $0.append(contentsOf: jobs) } + pendingJobsQueue.mutate { $0.append(contentsOf: jobs) } // Start the job runner if needed if canStart && !isRunning.wrappedValue { @@ -723,8 +794,8 @@ private final class JobQueue { canStart: Bool, dependencies: Dependencies ) { - queue.mutate { queue in - // Avoid re-adding jobs to the queue that are already in it (this can + pendingJobsQueue.mutate { queue in + // Avoid re-adding jobs to the pendingJobsQueue that are already in it (this can // happen if the user sends the app to the background before the 'onActive' // jobs and then brings it back to the foreground) let jobsNotAlreadyInQueue: [Job] = jobs @@ -739,16 +810,8 @@ private final class JobQueue { } } - fileprivate func isCurrentlyRunning(_ jobId: Int64) -> Bool { - return jobsCurrentlyRunning.wrappedValue.contains(jobId) - } - - fileprivate func detailsForAllCurrentlyRunningJobs() -> [Int64: Data?] { - return detailsForCurrentlyRunningJobs.wrappedValue - } - fileprivate func afterCurrentlyRunningJob(_ jobId: Int64, callback: @escaping (JobRunner.JobResult) -> ()) { - guard isCurrentlyRunning(jobId) else { + guard jobsCurrentlyRunning.wrappedValue.contains(jobId) else { callback(.notFound) return } @@ -758,14 +821,8 @@ private final class JobQueue { } } - fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { - let pendingJobs: [Job] = queue.wrappedValue - - return pendingJobs.contains { job in job.details == detailsData } - } - fileprivate func removePendingJob(_ jobId: Int64) { - queue.mutate { queue in + pendingJobsQueue.mutate { queue in queue = queue.filter { $0.id != jobId } } } @@ -773,17 +830,12 @@ private final class JobQueue { // MARK: - Job Running fileprivate func start( - force: Bool = false, + forceWhenAlreadyRunning: Bool = false, dependencies: Dependencies ) { - // We only want the JobRunner to run in the main app - guard - HasAppContext() && - CurrentAppContext().isMainApp && - !CurrentAppContext().isRunningTests && - JobRunner.canStart(queue: self) - else { return } - guard force || !isRunning.wrappedValue else { return } + // Only start if the JobRunner is allowed to start the queue + guard dependencies.jobRunner.canStart(queue: self) else { return } + guard forceWhenAlreadyRunning || !isRunning.wrappedValue else { return } // The JobRunner runs synchronously we need to ensure this doesn't start // on the main thread (if it is on the main thread then swap to a different thread) @@ -804,7 +856,7 @@ private final class JobQueue { // Get any pending jobs let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue - let jobsAlreadyInQueue: Set = queue.wrappedValue.compactMap { $0.id }.asSet() + let jobsAlreadyInQueue: Set = pendingJobsQueue.wrappedValue.compactMap { $0.id }.asSet() let jobsToRun: [Job] = dependencies.storage.read { db in try Job .filterPendingJobs( @@ -821,7 +873,7 @@ private final class JobQueue { // Determine the number of jobs to run var jobCount: Int = 0 - queue.mutate { queue in + pendingJobsQueue.mutate { queue in queue.append(contentsOf: jobsToRun) jobCount = queue.count } @@ -836,7 +888,7 @@ private final class JobQueue { return } - // Run the first job in the queue + // Run the first job in the pendingJobsQueue if !wasAlreadyRunning { SNLog("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") } @@ -845,7 +897,7 @@ private final class JobQueue { fileprivate func stopAndClearPendingJobs() { isRunning.mutate { $0 = false } - queue.mutate { $0 = [] } + pendingJobsQueue.mutate { $0 = [] } deferLoopTracker.mutate { $0 = [:] } } @@ -860,7 +912,7 @@ private final class JobQueue { } return } - guard let (nextJob, numJobsRemaining): (Job, Int) = queue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { + guard let (nextJob, numJobsRemaining): (Job, Int) = pendingJobsQueue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { // If it's a serial queue, or there are no more jobs running then update the 'isRunning' flag if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { isRunning.mutate { $0 = false } @@ -913,19 +965,21 @@ private final class JobQueue { } // If the 'nextRunTimestamp' for the job is in the future then don't run it yet - guard nextJob.nextRunTimestamp <= Date().timeIntervalSince1970 else { + guard nextJob.nextRunTimestamp <= dependencies.date.timeIntervalSince1970 else { handleJobDeferred(nextJob, dependencies: dependencies) return } // Check if the next job has any dependencies - let dependencyInfo: (expectedCount: Int, jobs: [Job]) = dependencies.storage.read { db in - let numExpectedDependencies: Int = try JobDependencies + let dependencyInfo: (expectedCount: Int, jobs: Set) = dependencies.storage.read { db in + let expectedDependencies: Set = try JobDependencies .filter(JobDependencies.Columns.jobId == nextJob.id) - .fetchCount(db) - let jobDependencies: [Job] = try nextJob.dependencies.fetchAll(db) + .fetchSet(db) + let jobDependencies: Set = try Job + .filter(ids: expectedDependencies.compactMap { $0.dependantId }) + .fetchSet(db) - return (numExpectedDependencies, jobDependencies) + return (expectedDependencies.count, jobDependencies) } .defaulting(to: (0, [])) @@ -942,39 +996,15 @@ private final class JobQueue { guard dependencyInfo.jobs.isEmpty else { SNLog("[JobRunner] \(queueContext) found job with \(dependencyInfo.jobs.count) dependencies, running those first") - let jobDependencyIds: [Int64] = dependencyInfo.jobs - .compactMap { $0.id } - let jobIdsNotInQueue: Set = jobDependencyIds - .asSet() - .subtracting(queue.wrappedValue.compactMap { $0.id }) - - // If there are dependencies which aren't in the queue we should just append them - guard !jobIdsNotInQueue.isEmpty else { - queue.mutate { queue in - queue.append( - contentsOf: dependencyInfo.jobs - .filter { jobIdsNotInQueue.contains($0.id ?? -1) } - ) - queue.append(nextJob) - } - handleJobDeferred(nextJob, dependencies: dependencies) - return + /// Remove all jobs this one is dependant on from the queue and re-insert them at the start of the queue + /// + /// **Note:** We don't add the current job back the the queue because it should only be re-added if it's dependencies + /// are successfully completed + pendingJobsQueue.mutate { queue in + queue = queue + .filter { !dependencyInfo.jobs.contains($0) } + .inserting(contentsOf: Array(dependencyInfo.jobs), at: 0) } - - // Otherwise re-add the current job after it's dependencies (if this isn't a concurrent - // queue - don't want to immediately try to start the job again only for it to end up back - // in here) - if executionType != .concurrent { - queue.mutate { queue in - guard let lastDependencyIndex: Int = queue.lastIndex(where: { jobDependencyIds.contains($0.id ?? -1) }) else { - queue.append(nextJob) - return - } - - queue.insert(nextJob, at: lastDependencyIndex + 1) - } - } - handleJobDeferred(nextJob, dependencies: dependencies) return } @@ -993,10 +1023,10 @@ private final class JobQueue { jobsCurrentlyRunning = jobsCurrentlyRunning.inserting(nextJob.id) numJobsRunning = jobsCurrentlyRunning.count } - detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(nextJob.id, nextJob.details) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(JobKey(nextJob), nextJob.details) } SNLog("[JobRunner] \(queueContext) started \(nextJob.variant) job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)") - /// As it turns out Combine doesn't plat too nicely with concurrent Dispatch Queues, in Combine events are dispatched asynchronously to + /// As it turns out Combine doesn't play too nicely with concurrent Dispatch Queues, in Combine events are dispatched asynchronously to /// the queue which means an odd situation can occasionally occur where the `finished` event can actually run before the `output` /// event - this can result in unexpected behaviours (for more information see https://github.com/groue/GRDB.swift/issues/1334) /// @@ -1049,7 +1079,7 @@ private final class JobQueue { // If there are no remaining jobs or the JobRunner isn't allowed to start any queues then trigger // the 'onQueueDrained' callback and stop - guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, JobRunner.canStart(queue: self) else { + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, dependencies.jobRunner.canStart(queue: self) else { if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { self.onQueueDrained?() } @@ -1073,7 +1103,7 @@ private final class JobQueue { // queue (for concurrent queues we want to force them to load in pending jobs and add // them to the queue regardless of whether the queue is already running) internalQueue.async { [weak self] in - self?.start(force: (self?.executionType == .concurrent), dependencies: dependencies) + self?.start(forceWhenAlreadyRunning: (self?.executionType == .concurrent), dependencies: dependencies) } return } @@ -1097,11 +1127,17 @@ private final class JobQueue { shouldStop: Bool, dependencies: Dependencies ) { + /// Retrieve the dependant jobs first (the `JobDependecies` table has cascading deletion when the original `Job` is + /// removed so we need to retrieve these records before that happens) + let dependantJobs: [Job] = dependencies.storage + .read { db in try job.dependantJobs.fetchAll(db) } + .defaulting(to: []) + switch job.behaviour { case .runOnce, .runOnceNextLaunch: dependencies.storage.write { db in - // First remove any JobDependencies requiring this job to be completed (if - // we don't then the dependant jobs will automatically be deleted) + /// Since this job has been completed we can update the dependencies so other job that were dependant + /// on this one can be run _ = try JobDependencies .filter(JobDependencies.Columns.dependantId == job.id) .deleteAll(db) @@ -1111,8 +1147,8 @@ private final class JobQueue { case .recurring where shouldStop == true: dependencies.storage.write { db in - // First remove any JobDependencies requiring this job to be completed (if - // we don't then the dependant jobs will automatically be deleted) + /// Since this job has been completed we can update the dependencies so other job that were dependant + /// on this one can be run _ = try JobDependencies .filter(JobDependencies.Columns.dependantId == job.id) .deleteAll(db) @@ -1120,9 +1156,8 @@ private final class JobQueue { _ = try job.delete(db) } - // For `recurring` jobs which have already run, they should automatically run again - // but we want at least 1 second to pass before doing so - the job itself should - // really update it's own 'nextRunTimestamp' (this is just a safety net) + /// For `recurring` jobs which have already run, they should automatically run again but we want at least 1 second + /// to pass before doing so - the job itself should really update it's own `nextRunTimestamp` (this is just a safety net) case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970: guard let jobId: Int64 = job.id else { break } @@ -1136,9 +1171,8 @@ private final class JobQueue { ) } - // For `recurringOnLaunch/Active` jobs which have already run but failed once, we need to - // clear their `failureCount` and `nextRunTimestamp` to prevent them from endlessly running - // over and over again + /// For `recurringOnLaunch/Active` jobs which have already run but failed once, we need to clear their + /// `failureCount` and `nextRunTimestamp` to prevent them from endlessly running over and over again case .recurringOnLaunch, .recurringOnActive: guard let jobId: Int64 = job.id, @@ -1159,26 +1193,17 @@ private final class JobQueue { default: break } - // For concurrent queues retrieve any 'dependant' jobs and re-add them here (if they have other - // dependencies they will be removed again when they try to execute) - if executionType == .concurrent { - let dependantJobs: [Job] = dependencies.storage - .read { db in try job.dependantJobs.fetchAll(db) } - .defaulting(to: []) - let dependantJobIds: [Int64] = dependantJobs - .compactMap { $0.id } - let jobIdsNotInQueue: Set = dependantJobIds - .asSet() - .subtracting(queue.wrappedValue.compactMap { $0.id }) - - // If there are dependant jobs which aren't in the queue we should just append them - if !jobIdsNotInQueue.isEmpty { - queue.mutate { queue in - queue.append( - contentsOf: dependantJobs - .filter { jobIdsNotInQueue.contains($0.id ?? -1) } - ) - } + /// Now that the job has been completed we want to insert any jobs that were dependant on it to the start of the queue (the + /// most likely case is that we want an entire job chain to be completed at the same time rather than being blocked by other + /// unrelated jobs) + /// + /// **Note:** If any of these `dependantJobs` have other dependencies then when they attempt to start they will be + /// removed from the queue, replaced by their dependencies + if !dependantJobs.isEmpty { + pendingJobsQueue.mutate { queue in + queue = queue + .filter { !dependantJobs.contains($0) } + .inserting(contentsOf: dependantJobs, at: 0) } } @@ -1197,7 +1222,7 @@ private final class JobQueue { permanentFailure: Bool, dependencies: Dependencies ) { - guard Storage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { + guard dependencies.storage.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { SNLog("[JobRunner] \(queueContext) \(job.variant) job canceled") performCleanUp(for: job, result: .failed) @@ -1229,7 +1254,7 @@ private final class JobQueue { // Only add it back to the queue if it wasn't a deferral loop if !wasPossibleDeferralLoop { - queue.mutate { $0.insert(job, at: 0) } + pendingJobsQueue.mutate { $0.insert(job, at: 0) } } internalQueue.async { [weak self] in @@ -1242,20 +1267,30 @@ private final class JobQueue { let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) - Storage.shared.write { db in + dependencies.storage.write { db in + /// Remove any dependant jobs from the queue (shouldn't be in there but filter the queue just in case so we don't try + /// to run a deleted job or get stuck in a loop of trying to run dependencies indefinitely) + let dependantJobIds: [Int64] = try job.dependantJobs + .select(.id) + .asRequest(of: Int64.self) + .fetchAll(db) + + if !dependantJobIds.isEmpty { + pendingJobsQueue.mutate { queue in + queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } + } + } + + /// Delete/update the failed jobs and any dependencies + let updatedFailureCount: UInt = (job.failureCount + 1) guard !permanentFailure && ( maxFailureCount < 0 || - job.failureCount + 1 < maxFailureCount + updatedFailureCount <= maxFailureCount ) else { SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")") - let dependantJobIds: [Int64] = try job.dependantJobs - .select(.id) - .asRequest(of: Int64.self) - .fetchAll(db) - // If the job permanently failed or we have performed all of our retry attempts // then delete the job and all of it's dependant jobs (it'll probably never succeed) _ = try job.dependantJobs @@ -1263,48 +1298,28 @@ private final class JobQueue { _ = try job.delete(db) - // Remove the dependant jobs from the queue (so we don't try to run a deleted job) - if !dependantJobIds.isEmpty { - queue.mutate { queue in - queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } - } - } - performCleanUp(for: job, result: .failed) return } - SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))") + SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; scheduling retry (failure count is \(updatedFailureCount))") _ = try job .with( - failureCount: (job.failureCount + 1), + failureCount: updatedFailureCount, nextRunTimestamp: nextRunTimestamp ) .saved(db) // Update the failureCount and nextRunTimestamp on dependant jobs as well (update the - // 'nextRunTimestamp' value to be 1ms later so when the queue gets regenerated it'll + // 'nextRunTimestamp' value to be 1ms later so when the queue gets regenerated they'll // come after the dependency) try job.dependantJobs .updateAll( db, - Job.Columns.failureCount.set(to: (job.failureCount + 1)), + Job.Columns.failureCount.set(to: updatedFailureCount), Job.Columns.nextRunTimestamp.set(to: (nextRunTimestamp + (1 / 1000))) ) - - let dependantJobIds: [Int64] = try job.dependantJobs - .select(.id) - .asRequest(of: Int64.self) - .fetchAll(db) - - // Remove the dependant jobs from the queue (so we don't get stuck in a loop of trying - // to run dependecies indefinitely) - if !dependantJobIds.isEmpty { - queue.mutate { queue in - queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } - } - } } performCleanUp(for: job, result: .failed) @@ -1315,7 +1330,7 @@ private final class JobQueue { /// This function is called when a job neither succeeds or fails (this should only occur if the job has specific logic that makes it dependant /// on other jobs, and it should automatically manage those dependencies) - private func handleJobDeferred( + public func handleJobDeferred( _ job: Job, dependencies: Dependencies ) { @@ -1375,7 +1390,7 @@ private final class JobQueue { // The job is removed from the queue before it runs so all we need to to is remove it // from the 'currentlyRunning' set jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: JobKey(job)) } guard shouldTriggerCallbacks else { return } @@ -1391,3 +1406,74 @@ private final class JobQueue { } } } + +// MARK: - JobRunner Singleton +// FIXME: Remove this once the jobRunner is dependency injected everywhere correctly +public extension JobRunner { + internal static let instance: JobRunner = JobRunner() + + // MARK: - Static Access + + static func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { + instance.setExecutor(executor, for: variant) + } + + static func appDidFinishLaunching(dependencies: Dependencies = Dependencies()) { + instance.appDidFinishLaunching(dependencies: dependencies) + } + + static func appDidBecomeActive(dependencies: Dependencies = Dependencies()) { + instance.appDidBecomeActive(dependencies: dependencies) + } + + /// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start + /// the JobRunner + /// + /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` + /// is in the future then the job won't be started + static func add( + _ db: Database, + job: Job?, + canStartJob: Bool = true, + dependencies: Dependencies = Dependencies() + ) { instance.add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } + + /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start + /// the JobRunner + /// + /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` + /// is in the future then the job won't be started + static func upsert( + _ db: Database, + job: Job?, + canStartJob: Bool = true, + dependencies: Dependencies = Dependencies() + ) { instance.upsert(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } + + @discardableResult static func insert( + _ db: Database, + job: Job?, + before otherJob: Job, + dependencies: Dependencies = Dependencies() + ) -> (Int64, Job)? { instance.insert(db, job: job, before: otherJob, dependencies: dependencies) } + + /// Calling this will clear the JobRunner queues and stop it from running new jobs, any currently executing jobs will continue to run + /// though (this means if we suspend the database it's likely that any currently running jobs will fail to complete and fail to record their + /// failure - they _should_ be picked up again the next time the app is launched) + static func stopAndClearPendingJobs( + exceptForVariant: Job.Variant? = nil, + onComplete: (() -> ())? = nil + ) { instance.stopAndClearPendingJobs(exceptForVariant: exceptForVariant, onComplete: onComplete) } + + static func isCurrentlyRunning(_ job: Job?) -> Bool { + return instance.isCurrentlyRunning(job) + } + + static func afterCurrentlyRunningJob(_ job: Job?, callback: @escaping (JobResult) -> ()) { + instance.afterCurrentlyRunningJob(job, callback: callback) + } + + static func removePendingJob(_ job: Job?) { + instance.removePendingJob(job) + } +} diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift index b6dfcc2d7..8c4db0211 100644 --- a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -9,7 +9,7 @@ import Nimble @testable import SessionUtilitiesKit class JobRunnerSpec: QuickSpec { - public enum TestSuccessfulJob: JobExecutor { + enum TestSuccessfulJob: JobExecutor { static let maxFailureCount: Int = 0 static let requiresThreadId: Bool = false static let requiresInteractionId: Bool = false @@ -22,14 +22,93 @@ class JobRunnerSpec: QuickSpec { deferred: @escaping (Job, Dependencies) -> (), dependencies: Dependencies ) { - success(job, true, dependencies) + guard dependencies.date.timeIntervalSinceNow > 0 else { return success(job, true, dependencies) } + + queue.asyncAfter(deadline: .now() + .milliseconds(Int(dependencies.date.timeIntervalSinceNow * 1000))) { + success(job, true, dependencies) + } } } + enum TestFailedJob: JobExecutor { + static let maxFailureCount: Int = 1 + static let requiresThreadId: Bool = false + static let requiresInteractionId: Bool = false + + static func run( + _ job: Job, + queue: DispatchQueue, + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies + ) { + guard dependencies.date.timeIntervalSinceNow > 0 else { return failure(job, nil, false, dependencies) } + + queue.asyncAfter(deadline: .now() + .milliseconds(Int(dependencies.date.timeIntervalSinceNow * 1000))) { + failure(job, nil, false, dependencies) + } + } + } + + enum TestPermanentFailureJob: JobExecutor { + static let maxFailureCount: Int = 1 + static let requiresThreadId: Bool = false + static let requiresInteractionId: Bool = false + + static func run( + _ job: Job, + queue: DispatchQueue, + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies + ) { + guard dependencies.date.timeIntervalSinceNow > 0 else { return failure(job, nil, true, dependencies) } + + queue.asyncAfter(deadline: .now() + .milliseconds(Int(dependencies.date.timeIntervalSinceNow * 1000))) { + failure(job, nil, true, dependencies) + } + } + } + + enum TestDeferredJob: JobExecutor { + static let maxFailureCount: Int = 0 + static let requiresThreadId: Bool = false + static let requiresInteractionId: Bool = false + + static func run( + _ job: Job, + queue: DispatchQueue, + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies + ) { + guard dependencies.date.timeIntervalSinceNow > 0 else { return deferred(job, dependencies) } + + queue.asyncAfter(deadline: .now() + .milliseconds(Int(dependencies.date.timeIntervalSinceNow * 1000))) { + deferred(job, dependencies) + } + } + } + + struct TestDetails: Codable { + public let intValue: Int64 + public let stringValue: String + } + + struct InvalidDetails: Codable { + func encode(to encoder: Encoder) throws { throw HTTP.Error.parsingFailed } + } + // MARK: - Spec override func spec() { - var jobRunner: JobRunner! + var jobRunner: JobRunnerType! + var job1: Job! + var job2: Job! + var jobDetails: TestDetails! var mockStorage: Storage! var dependencies: Dependencies! @@ -48,21 +127,1150 @@ class JobRunnerSpec: QuickSpec { date: Date(timeIntervalSince1970: 1234567890) ) - jobRunner = JobRunner() + // Migrations add jobs which we don't want so delete them + mockStorage.write { db in try Job.deleteAll(db) } + + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + jobDetails = TestDetails( + intValue: 100, + stringValue: "200" + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .attachmentUpload, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try! JSONEncoder().encode(jobDetails) + ) + + jobRunner = JobRunner(isTestingJobRunner: true, dependencies: dependencies) + + // Need to assign this to ensure it's used by nested dependencies + dependencies.jobRunner = jobRunner } afterEach { + jobRunner.stopAndClearPendingJobs() jobRunner = nil mockStorage = nil dependencies = nil } + // MARK: -- when configuring context("when configuring") { it("adds an executor correctly") { - // TODO: Test this - jobRunner.add(executor: TestSuccessfulJob.self, for: .messageSend) + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + // First check that it fails to start + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + + jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) + + // Then check that it succeeded to start + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) } } + + // MARK: -- when managing state + + context("when managing state") { + + // MARK: ---- by checking if a job is currently running + + context("by checking if a job is currently running") { + beforeEach { + jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) + } + + it("returns false when not given a job") { + expect(jobRunner.isCurrentlyRunning(nil)).to(beFalse()) + } + + it("returns false when given a job that has not been persisted") { + job1 = Job(variant: .messageSend) + + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + } + + it("returns false when given a job that is not running") { + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + } + + it("returns true when given a non blocking job that is running") { + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beTrue(), + timeout: .milliseconds(10) + ) + } + + it("returns true when given a blocking job that is running") { + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceNextLaunch, + shouldBlock: true, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + + mockStorage.write { db in + try job2.insert(db) + + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + expect(jobRunner.isCurrentlyRunning(job2)) + .toEventually( + beTrue(), + timeout: .milliseconds(10) + ) + } + } + + // MARK: ---- by getting the details for jobs + + context("by getting the details for jobs") { + beforeEach { + jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) + jobRunner.setExecutor(TestSuccessfulJob.self, for: .attachmentUpload) + jobRunner.setExecutor(TestSuccessfulJob.self, for: .attachmentDownload) + } + + it("returns an empty dictionary when there are no jobs") { + expect(jobRunner.details()).to(equal([:])) + } + + it("returns an empty dictionary when there are no jobs matching the filters") { + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(jobRunner.detailsFor(state: .running, variant: .messageSend)) + .toEventually( + equal([:]), + timeout: .milliseconds(10) + ) + } + + it("can filter to specific jobs") { + mockStorage.write { db in + jobRunner.upsert( + db, + job: job2, + /// The `canStartJob` value needs to be `true` for the job to be added to the queue but as + /// long as `appDidFinishLaunching` hasn't been called it won't actually start running and + /// as a result we can test the "pending" state + canStartJob: true, + dependencies: dependencies + ) + } + + // Wait for there to be data and the validate the filtering works + expect(jobRunner.details()) + .toEventuallyNot( + beEmpty(), + timeout: .milliseconds(10) + ) + expect(jobRunner.detailsFor(jobs: [job1])).to(equal([:])) + expect(jobRunner.detailsFor(jobs: [job2])).to(equal([101: job2.details])) + } + + it("can filter to running jobs") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .attachmentDownload, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try! JSONEncoder().encode(jobDetails) + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .attachmentDownload, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + // Wait for there to be data and the validate the filtering works + expect(jobRunner.detailsFor(state: .running)) + .toEventually( + equal([100: try! JSONEncoder().encode(jobDetails)]), + timeout: .milliseconds(10) + ) + expect(Array(jobRunner.details().keys).sorted()).to(equal([100, 101])) + } + + it("can filter to pending jobs") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .attachmentDownload, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .attachmentDownload, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try! JSONEncoder().encode(jobDetails) + ) + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + // Wait for there to be data and the validate the filtering works + expect(jobRunner.detailsFor(state: .pending)) + .toEventually( + equal([101: try! JSONEncoder().encode(jobDetails)]), + timeout: .milliseconds(10) + ) + expect(Array(jobRunner.details().keys).sorted()).to(equal([100, 101])) + } + + it("can filter to specific variants") { + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + // Wait for there to be data and the validate the filtering works + expect(jobRunner.detailsFor(variant: .attachmentUpload)) + .toEventually( + equal([101: try! JSONEncoder().encode(jobDetails)]), + timeout: .milliseconds(10) + ) + expect(Array(jobRunner.details().keys).sorted()).to(equal([100, 101])) + } + + it("includes non blocking jobs") { + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(jobRunner.detailsFor(state: .running, variant: .attachmentUpload)) + .toEventually( + equal([101: try! JSONEncoder().encode(jobDetails)]), + timeout: .milliseconds(10) + ) + } + + it("includes blocking jobs") { + job2 = Job( + id: 101, + failureCount: 0, + variant: .attachmentUpload, + behaviour: .runOnceNextLaunch, + shouldBlock: true, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try! JSONEncoder().encode(jobDetails) + ) + + mockStorage.write { db in + try job2.insert(db) + + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + expect(jobRunner.detailsFor(state: .running, variant: .attachmentUpload)) + .toEventually( + equal([101: try! JSONEncoder().encode(jobDetails)]), + timeout: .milliseconds(10) + ) + } + } + + // MARK: ---- by checking for an existing job + + context("by checking for an existing job") { + beforeEach { + jobRunner.setExecutor(TestSuccessfulJob.self, for: .attachmentUpload) + } + + it("returns false for a queue that doesn't exist") { + jobRunner = JobRunner( + isTestingJobRunner: true, + variantsToExclude: [.attachmentUpload], + dependencies: dependencies + ) + + expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + .to(beFalse()) + } + + it("returns false when the provided details fail to decode") { + expect(jobRunner.hasJob(of: .attachmentUpload, with: InvalidDetails())) + .to(beFalse()) + } + + it("returns false when there is not a pending or running job") { + expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + .to(beFalse()) + } + + it("returns true when there is a pending job") { + mockStorage.write { db in + jobRunner.upsert( + db, + job: job2, + /// The `canStartJob` value needs to be `true` for the job to be added to the queue but as + /// long as `appDidFinishLaunching` hasn't been called it won't actually start running and + /// as a result we can test the "pending" state + canStartJob: true, + dependencies: dependencies + ) + } + + expect(Array(jobRunner.detailsFor(state: .pending, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + .to(beTrue()) + } + + it("returns true when there is a running job") { + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + .to(beTrue()) + } + + it("returns true when there is a blocking job") { + job2 = Job( + id: 101, + failureCount: 0, + variant: .attachmentUpload, + behaviour: .runOnceNextLaunch, + shouldBlock: true, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try! JSONEncoder().encode(jobDetails) + ) + + mockStorage.write { db in + try job2.insert(db) + + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + .to(beTrue()) + } + + it("returns true when there is a non blocking job") { + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + .to(beTrue()) + } + } + + // MARK: ---- by being notified of app launch + + context("by being notified of app launch") { + beforeEach { + jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) + } + + it("does not start a job before getting the app launch call") { + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + } + + it("does nothing if there are no app launch jobs") { + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + } + + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + } + + it("starts the job queues after completing blocking app launch jobs") { + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceNextLaunch, + shouldBlock: true, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + + mockStorage.write { db in + try job2.insert(db) + + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + // Not currently running + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) + + // Make sure it starts + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + // Blocking job running but blocked job not + expect(jobRunner.isCurrentlyRunning(job2)) + .toEventually( + beTrue(), + timeout: .milliseconds(10) + ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + + // Blocked job eventually starts + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beTrue(), + timeout: .milliseconds(20) + ) + } + + it("starts the job queues alongside non blocking app launch jobs") { + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceNextLaunch, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + + mockStorage.write { db in + try job2.insert(db) + + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + // Not currently running + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + + // Make sure it starts + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beTrue(), + timeout: .milliseconds(10) + ) + expect(jobRunner.isCurrentlyRunning(job2)) + .toEventually( + beTrue(), + timeout: .milliseconds(10) + ) + } + } + + // MARK: ---- by being notified of app becoming active + + context("by being notified of app becoming active") { + beforeEach { + jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) + } + + it("does not start a job before getting the app active call") { + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + } + + it("does not start the job queues if there are no app active jobs and blocking jobs are running") { + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceNextLaunch, + shouldBlock: true, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + + mockStorage.write { db in + try job2.insert(db) + + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + // Not currently running + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + + // Start the blocking job + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + // Make sure the other queues don't start + dependencies.date = Date().addingTimeInterval(30 / 1000) // Complete job after delay + jobRunner.appDidBecomeActive(dependencies: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(20) + ) + } + + it("does not start the job queues if there are app active jobs and blocking jobs are running") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnActive, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceNextLaunch, + shouldBlock: true, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + // Not currently running + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + + // Start the blocking queue + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidFinishLaunching(dependencies: dependencies) + + // Make sure the other queues don't start + dependencies.date = Date().addingTimeInterval(30 / 1000) // Complete job after delay + jobRunner.appDidBecomeActive(dependencies: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(20) + ) + } + + it("starts the job queues if there are no app active jobs") { + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + } + + jobRunner.appDidBecomeActive(dependencies: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beTrue(), + timeout: .milliseconds(10) + ) + } + + it("starts the job queues if there are app active jobs") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnActive, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: nil + ) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + jobRunner.upsert( + db, + job: job2, + canStartJob: true, + dependencies: dependencies + ) + } + + // Not currently running + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(10) + ) + + // Make sure the queues are started + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + jobRunner.appDidBecomeActive(dependencies: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beTrue(), + timeout: .milliseconds(10) + ) + expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) + } + } + } + + // MARK: -- when running jobs + + context("when running jobs") { + beforeEach { + jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) + jobRunner.setExecutor(TestSuccessfulJob.self, for: .attachmentUpload) + jobRunner.appDidFinishLaunching(dependencies: dependencies) + } + + // MARK: ---- with dependencies + + context("with dependencies") { + it("starts dependencies first") { + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + } + + it("removes the initial job from the queue") { + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the initial job is removed from the queue + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + } + + it("starts the initial job when the dependencies succeed") { + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure the initial job starts + dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + expect(Array(jobRunner.detailsFor(state: .running, variant: .messageSend).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(20) + ) + } + + it("does not start the initial job if the dependencies fail") { + jobRunner.setExecutor(TestFailedJob.self, for: .attachmentUpload) + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Fail job after delay + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure there are no running jobs + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(20) + ) + } + + it("does not delete the initial job if the dependencies fail") { + jobRunner.setExecutor(TestFailedJob.self, for: .attachmentUpload) + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Fail job after delay + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure there are no running jobs + dependencies.date = Date().addingTimeInterval(20 / 1000) // Delay subsequent runs + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(20) + ) + + // Stop the queues so it doesn't run out of retry attempts + jobRunner.stopAndClearPendingJobs(exceptForVariant: nil, onComplete: nil) + + // Make sure the jobs still exist + expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(2)) + } + + it("deletes the initial job if the dependencies permanently fail") { + jobRunner.setExecutor(TestPermanentFailureJob.self, for: .attachmentUpload) + + dependencies.date = Date().addingTimeInterval(20 / 1000) // Fail job after delay + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(10) + ) + expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure there are no running jobs + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(20) + ) + + // Make sure the jobs were deleted + expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) + } + } + + } } } } diff --git a/_SharedTestUtilities/CommonMockedExtensions.swift b/_SharedTestUtilities/CommonMockedExtensions.swift index 052931525..b70e06327 100644 --- a/_SharedTestUtilities/CommonMockedExtensions.swift +++ b/_SharedTestUtilities/CommonMockedExtensions.swift @@ -1,8 +1,10 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Sodium import Curve25519Kit +import SessionUtilitiesKit extension Box.KeyPair: Mocked { static var mockValue: Box.KeyPair = Box.KeyPair( @@ -19,3 +21,19 @@ extension ECKeyPair: Mocked { ) } } + +extension Database: Mocked { + static var mockValue: Database { + var result: Database! + try! DatabaseQueue().read { result = $0 } + return result! + } +} + +extension Job: Mocked { + static var mockValue: Job = Job(variant: .messageSend) +} + +extension Job.Variant: Mocked { + static var mockValue: Job.Variant = .messageSend +} diff --git a/_SharedTestUtilities/MockJobRunner.swift b/_SharedTestUtilities/MockJobRunner.swift new file mode 100644 index 000000000..494643ed2 --- /dev/null +++ b/_SharedTestUtilities/MockJobRunner.swift @@ -0,0 +1,56 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class MockJobRunner: Mock, JobRunnerType { + // MARK: - Configuration + + func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { + accept(args: [executor, variant]) + } + + func canStart(queue: JobQueue) -> Bool { + return accept(args: [queue]) as! Bool + } + + // MARK: - State Management + + func isCurrentlyRunning(_ job: Job?) -> Bool { + return accept(args: [job]) as! Bool + } + + func hasJob(of variant: Job.Variant, inState state: JobRunner.JobState, with jobDetails: T) -> Bool { + return accept(args: [variant, state, jobDetails]) as! Bool + } + + func detailsFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: Data?] { + return accept(args: [jobs, state, variant]) as! [Int64: Data?] + } + + func appDidFinishLaunching(dependencies: Dependencies) {} + func appDidBecomeActive(dependencies: Dependencies) {} + func startNonBlockingQueues(dependencies: Dependencies) {} + + func stopAndClearPendingJobs(exceptForVariant: Job.Variant?, onComplete: (() -> ())?) { + accept(args: [exceptForVariant, onComplete]) + onComplete?() + } + + // MARK: - Job Scheduling + + func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) { + accept(args: [db, job, canStartJob]) + } + + func upsert(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) { + accept(args: [db, job, canStartJob]) + } + + func insert(_ db: Database, job: Job?, before otherJob: Job, dependencies: Dependencies) -> (Int64, Job)? { + return accept(args: [db, job, otherJob]) as? (Int64, Job) + } +} From 4330a40f6f725b5921bb9f8eff6cbf4f0d523ad5 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 19 May 2023 17:26:14 +1000 Subject: [PATCH 03/50] Started working on integrating the updated push APIs Updated the PushNotificationAPI to be more consistent with the SnodeAPI and OpenGroupAPI structures Updated the logic so if the database key can't be retrieved the app will no longer throw a fatalError (now just fail to initialise Storage and rely on the App/Extensions to properly handle this case) Fixed a couple of bugs where the share extension wouldn't populate correctly --- Session.xcodeproj/project.pbxproj | 52 +- Session/Meta/AppDelegate.swift | 17 +- .../Translations/de.lproj/Localizable.strings | 2 + .../Translations/en.lproj/Localizable.strings | 2 + .../Translations/es.lproj/Localizable.strings | 2 + .../Translations/fa.lproj/Localizable.strings | 2 + .../Translations/fi.lproj/Localizable.strings | 2 + .../Translations/fr.lproj/Localizable.strings | 2 + .../Translations/hi.lproj/Localizable.strings | 2 + .../Translations/hr.lproj/Localizable.strings | 2 + .../id-ID.lproj/Localizable.strings | 2 + .../Translations/it.lproj/Localizable.strings | 2 + .../Translations/ja.lproj/Localizable.strings | 2 + .../Translations/nl.lproj/Localizable.strings | 2 + .../Translations/pl.lproj/Localizable.strings | 2 + .../pt_BR.lproj/Localizable.strings | 2 + .../Translations/ru.lproj/Localizable.strings | 2 + .../Translations/si.lproj/Localizable.strings | 2 + .../Translations/sk.lproj/Localizable.strings | 2 + .../Translations/sv.lproj/Localizable.strings | 2 + .../Translations/th.lproj/Localizable.strings | 2 + .../vi-VN.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + .../zh_CN.lproj/Localizable.strings | 2 + Session/Notifications/SyncPushTokensJob.swift | 8 +- Session/Settings/NukeDataModal.swift | 5 +- .../Database/Models/ClosedGroup.swift | 7 +- .../Jobs/Types/NotifyPushServerJob.swift | 3 +- .../Open Groups/Models/SOGSMessage.swift | 2 +- .../Open Groups/OpenGroupManager.swift | 2 + .../MessageReceiver+ClosedGroups.swift | 20 +- .../MessageReceiver+VisibleMessages.swift | 6 +- .../MessageSender+ClosedGroups.swift | 9 +- .../Sending & Receiving/MessageReceiver.swift | 5 + .../Models/LegacyGroupRequest.swift | 10 + .../Models/LegacyNotifyRequest.swift | 15 + ...e.swift => LegacyPushServerResponse.swift} | 2 +- .../Models/PushNotificationAPIRequest.swift | 33 + .../Models/SubscribeRequest.swift | 153 +++++ .../Models/SubscribeResponse.swift | 31 + .../Models/UnsubscribeRequest.swift | 111 ++++ .../Models/UnsubscribeResponse.swift | 31 + .../Notifications/PushNotificationAPI.swift | 602 +++++++++++------- .../Types/PushNotificationAPIEndpoint.swift | 40 ++ .../Notifications/Types/Service.swift | 9 + .../ShareNavController.swift | 21 +- SessionShareExtension/ThreadPickerVC.swift | 19 +- SessionUtilitiesKit/Database/Storage.swift | 42 +- .../Database/StorageError.swift | 2 + SessionUtilitiesKit/General/SessionId.swift | 1 + SignalUtilitiesKit/Utilities/AppSetup.swift | 8 + 51 files changed, 1026 insertions(+), 284 deletions(-) create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupRequest.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyNotifyRequest.swift rename SessionMessagingKit/Sending & Receiving/Notifications/Models/{PushServerResponse.swift => LegacyPushServerResponse.swift} (78%) create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/PushNotificationAPIRequest.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeResponse.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeResponse.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a09fceb75..cbe919ba5 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -758,6 +758,15 @@ FDBB25E12983909300F1508E /* ConfigConvoInfoVolatileSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E02983909300F1508E /* ConfigConvoInfoVolatileSpec.swift */; }; FDBB25E32988B13800F1508E /* _004_AddJobPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */; }; FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */; }; + FDC13D472A16E4CA007267C7 /* SubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D462A16E4CA007267C7 /* SubscribeRequest.swift */; }; + FDC13D492A16EC20007267C7 /* Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D482A16EC20007267C7 /* Service.swift */; }; + FDC13D4B2A16ECBA007267C7 /* SubscribeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D4A2A16ECBA007267C7 /* SubscribeResponse.swift */; }; + FDC13D502A16EE50007267C7 /* PushNotificationAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D4F2A16EE50007267C7 /* PushNotificationAPIEndpoint.swift */; }; + FDC13D522A16F22E007267C7 /* PushNotificationAPIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D512A16F22E007267C7 /* PushNotificationAPIRequest.swift */; }; + FDC13D542A16FF29007267C7 /* LegacyGroupRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D532A16FF29007267C7 /* LegacyGroupRequest.swift */; }; + FDC13D562A171FE4007267C7 /* UnsubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D552A171FE4007267C7 /* UnsubscribeRequest.swift */; }; + FDC13D582A17207D007267C7 /* UnsubscribeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D572A17207D007267C7 /* UnsubscribeResponse.swift */; }; + FDC13D5A2A1721C5007267C7 /* LegacyNotifyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC13D592A1721C5007267C7 /* LegacyNotifyRequest.swift */; }; FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908627D7047F005DAE71 /* RoomSpec.swift */; }; FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */; }; FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908A27D707F3005DAE71 /* SendMessageRequestSpec.swift */; }; @@ -778,7 +787,7 @@ FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; - FDC4382F27B383AF00C60D73 /* PushServerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */; }; + FDC4382F27B383AF00C60D73 /* LegacyPushServerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* LegacyPushServerResponse.swift */; }; FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* VersionResponse.swift */; }; FDC4385D27B4C18900C60D73 /* Room.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385C27B4C18900C60D73 /* Room.swift */; }; FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */; }; @@ -1890,6 +1899,15 @@ FDBB25E02983909300F1508E /* ConfigConvoInfoVolatileSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigConvoInfoVolatileSpec.swift; sourceTree = ""; }; FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _004_AddJobPriority.swift; sourceTree = ""; }; FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Theming.swift"; sourceTree = ""; }; + FDC13D462A16E4CA007267C7 /* SubscribeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribeRequest.swift; sourceTree = ""; }; + FDC13D482A16EC20007267C7 /* Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Service.swift; sourceTree = ""; }; + FDC13D4A2A16ECBA007267C7 /* SubscribeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribeResponse.swift; sourceTree = ""; }; + FDC13D4F2A16EE50007267C7 /* PushNotificationAPIEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationAPIEndpoint.swift; sourceTree = ""; }; + FDC13D512A16F22E007267C7 /* PushNotificationAPIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationAPIRequest.swift; sourceTree = ""; }; + FDC13D532A16FF29007267C7 /* LegacyGroupRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGroupRequest.swift; sourceTree = ""; }; + FDC13D552A171FE4007267C7 /* UnsubscribeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsubscribeRequest.swift; sourceTree = ""; }; + FDC13D572A17207D007267C7 /* UnsubscribeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsubscribeResponse.swift; sourceTree = ""; }; + FDC13D592A1721C5007267C7 /* LegacyNotifyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyNotifyRequest.swift; sourceTree = ""; }; FDC2908627D7047F005DAE71 /* RoomSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSpec.swift; sourceTree = ""; }; FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollInfoSpec.swift; sourceTree = ""; }; FDC2908A27D707F3005DAE71 /* SendMessageRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequestSpec.swift; sourceTree = ""; }; @@ -1909,7 +1927,7 @@ FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpoint.swift; sourceTree = ""; }; - FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushServerResponse.swift; sourceTree = ""; }; + FDC4382E27B383AF00C60D73 /* LegacyPushServerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyPushServerResponse.swift; sourceTree = ""; }; FDC4383727B3863200C60D73 /* VersionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionResponse.swift; sourceTree = ""; }; FDC4383D27B4708600C60D73 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; FDC4385C27B4C18900C60D73 /* Room.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Room.swift; sourceTree = ""; }; @@ -3151,6 +3169,7 @@ C379DC6825672B5E0002D4EB /* Notifications */ = { isa = PBXGroup; children = ( + FDC13D4E2A16EE41007267C7 /* Types */, FDC4382D27B383A600C60D73 /* Models */, FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */, C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */, @@ -4140,6 +4159,15 @@ path = Configs; sourceTree = ""; }; + FDC13D4E2A16EE41007267C7 /* Types */ = { + isa = PBXGroup; + children = ( + FDC13D482A16EC20007267C7 /* Service.swift */, + FDC13D4F2A16EE50007267C7 /* PushNotificationAPIEndpoint.swift */, + ); + path = Types; + sourceTree = ""; + }; FDC2909227D710A9005DAE71 /* Types */ = { isa = PBXGroup; children = ( @@ -4192,7 +4220,14 @@ FDC4382D27B383A600C60D73 /* Models */ = { isa = PBXGroup; children = ( - FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */, + FDC13D512A16F22E007267C7 /* PushNotificationAPIRequest.swift */, + FDC13D462A16E4CA007267C7 /* SubscribeRequest.swift */, + FDC13D4A2A16ECBA007267C7 /* SubscribeResponse.swift */, + FDC13D552A171FE4007267C7 /* UnsubscribeRequest.swift */, + FDC13D572A17207D007267C7 /* UnsubscribeResponse.swift */, + FDC13D532A16FF29007267C7 /* LegacyGroupRequest.swift */, + FDC4382E27B383AF00C60D73 /* LegacyPushServerResponse.swift */, + FDC13D592A1721C5007267C7 /* LegacyNotifyRequest.swift */, ); path = Models; sourceTree = ""; @@ -5703,6 +5738,7 @@ FD245C672850665E00B966DD /* AttachmentDownloadJob.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, 7B521E0A29BFF84400C3C36A /* GroupLeavingJob.swift in Sources */, + FDC13D582A17207D007267C7 /* UnsubscribeResponse.swift in Sources */, FD09799927FFC1A300936362 /* Attachment.swift in Sources */, FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, @@ -5749,6 +5785,7 @@ FD37EA0F28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift in Sources */, FD2B4AFD294688D000AB4848 /* SessionUtil+Contacts.swift in Sources */, 7B81682328A4C1210069F315 /* UpdateTypes.swift in Sources */, + FDC13D472A16E4CA007267C7 /* SubscribeRequest.swift in Sources */, FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */, FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */, FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */, @@ -5757,6 +5794,7 @@ C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */, FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, + FDC13D542A16FF29007267C7 /* LegacyGroupRequest.swift in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, FD8ECF8B2935DB4B00C0D1BB /* SharedConfigMessage.swift in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, @@ -5766,6 +5804,7 @@ FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */, B8DE1FB426C22F2F0079C9CE /* WebRTCSession.swift in Sources */, + FDC13D5A2A1721C5007267C7 /* LegacyNotifyRequest.swift in Sources */, FDC6D6F32860607300B04575 /* Environment.swift in Sources */, C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, @@ -5794,16 +5833,18 @@ FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, - FDC4382F27B383AF00C60D73 /* PushServerResponse.swift in Sources */, + FDC4382F27B383AF00C60D73 /* LegacyPushServerResponse.swift in Sources */, FDC4386327B4D94E00C60D73 /* SOGSMessage.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */, FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */, FD43EE9D297A5190009C87C5 /* SessionUtil+UserGroups.swift in Sources */, FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */, + FDC13D4B2A16ECBA007267C7 /* SubscribeResponse.swift in Sources */, FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */, FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */, FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */, + FDC13D502A16EE50007267C7 /* PushNotificationAPIEndpoint.swift in Sources */, FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */, FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */, @@ -5831,7 +5872,9 @@ FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, + FDC13D492A16EC20007267C7 /* Service.swift in Sources */, FD778B6429B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift in Sources */, + FDC13D562A171FE4007267C7 /* UnsubscribeRequest.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */, @@ -5854,6 +5897,7 @@ FD245C682850666300B966DD /* Message+Destination.swift in Sources */, FDF8488029405994007DCAE5 /* HTTPQueryParam+OpenGroup.swift in Sources */, FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, + FDC13D522A16F22E007267C7 /* PushNotificationAPIRequest.swift in Sources */, FD245C632850664600B966DD /* Configuration.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 3de4c535c..bd6aed602 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -74,7 +74,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD }, migrationsCompletion: { [weak self] result, needsConfigSync in if case .failure(let error) = result { - self?.showFailedMigrationAlert(calledFrom: .finishLaunching, error: error) + self?.showDatabaseSetupFailureModal(calledFrom: .finishLaunching, error: error) return } @@ -145,7 +145,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD }, migrationsCompletion: { [weak self] result, needsConfigSync in if case .failure(let error) = result { - self?.showFailedMigrationAlert(calledFrom: .enterForeground, error: error) + self?.showDatabaseSetupFailureModal(calledFrom: .enterForeground, error: error) return } @@ -330,15 +330,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } - private func showFailedMigrationAlert(calledFrom lifecycleMethod: LifecycleMethod, error: Error?) { + private func showDatabaseSetupFailureModal(calledFrom lifecycleMethod: LifecycleMethod, error: Error?) { let alert = UIAlertController( title: "Session", - message: "DATABASE_MIGRATION_FAILED".localized(), + message: { + switch (error as? StorageError) { + case .databaseInvalid: return "DATABASE_SETUP_FAILED".localized() + default: return "DATABASE_MIGRATION_FAILED".localized() + } + }(), preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in HelpViewModel.shareLogs(viewControllerToDismiss: alert) { [weak self] in - self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error) + self?.showDatabaseSetupFailureModal(calledFrom: lifecycleMethod, error: error) } }) alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in @@ -359,7 +364,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD }, migrationsCompletion: { [weak self] result, needsConfigSync in if case .failure(let error) = result { - self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error) + self?.showDatabaseSetupFailureModal(calledFrom: lifecycleMethod, error: error) return } diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 4cc110972..7de00d961 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index c8a44a169..2f64e3165 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 23e1407ae..e15ce85a7 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 0f4ea4b7a..72682bc96 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "متاسفانه خطایی رخ داده است"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید"; "LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "هنگام بهینه‌سازی پایگاه داده خطایی روی داد\n\nشما می‌توانید گزارش‌های برنامه خود را صادر کنید تا بتوانید برای عیب‌یابی به اشتراک بگذارید یا می‌توانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن داده‌های قدیمی‌تر از دو هفته می‌شود."; "RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید."; "RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index 04b308b45..e212cc192 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index f5a82501b..3ba4e14c6 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oups, une erreur est survenue"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard"; "LOADING_CONVERSATIONS" = "Chargement des conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines"; "RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; "RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index fb2fd0ed9..c0bae9882 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 2f603b200..2dcb2114f 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 7a23f9aff..73632f8ee 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index c094b389d..18969699b 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index f092cd291..146814921 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 9315f29ac..bc1b3bcc0 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 8a2c7865f..1bda47473 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 1991bb588..53c834129 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 6d516c8bf..6058afaff 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 8f66d6a66..d2ef27707 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 08f7018f9..652259fac 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 6896d243c..055a05234 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index a1fe56cdd..055471f54 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 3b060a0ba..51d47e9cc 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 5dd8760a7..23786b3f6 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index c14cf0c85..a56b174b0 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -414,6 +414,7 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; "LOADING_CONVERSATIONS" = "Loading Conversations..."; +"DATABASE_SETUP_FAILED" = "An error occurred when opening the database, please restart to try again\n\nIf you contineu to see this error you can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; "RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; "RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; @@ -641,3 +642,4 @@ "CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; "USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; +"database_inaccessible_error" = "There is an issue opening the database. Please restart the app and try again."; diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 4ac93c7a8..90a6585c6 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -155,15 +155,15 @@ extension SyncPushTokensJob { .setFailureType(to: Error.self) .flatMap { pushTokenAsData -> AnyPublisher in guard isUsingFullAPNs else { - return PushNotificationAPI.unregister(pushTokenAsData) + return PushNotificationAPI + .unsubscribe(token: pushTokenAsData) .map { _ in true } .eraseToAnyPublisher() } return PushNotificationAPI - .register( - with: pushTokenAsData, - publicKey: getUserHexEncodedPublicKey(), + .subscribe( + token: pushTokenAsData, isForcedUpdate: isForcedUpdate ) .map { _ in true } diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 2d8ceeaff..18aa51a56 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -225,8 +225,9 @@ final class NukeDataModal: Modal { let maybeDeviceToken: String? = UserDefaults.standard[.deviceToken] if isUsingFullAPNs, let deviceToken: String = maybeDeviceToken { - let data: Data = Data(hex: deviceToken) - PushNotificationAPI.unregister(data).sinkUntilComplete() + PushNotificationAPI + .unsubscribe(token: Data(hex: deviceToken)) + .sinkUntilComplete() } // Clear the app badge and notifications diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 43e922ed0..de285bb2b 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -144,10 +144,9 @@ public extension ClosedGroup { ClosedGroupPoller.shared.stopPolling(for: threadId) PushNotificationAPI - .performOperation( - .unsubscribe, - for: threadId, - publicKey: userPublicKey + .unsubscribeFromLegacyGroup( + legacyGroupId: threadId, + currentUserPublicKey: userPublicKey ) .sinkUntilComplete() } diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index 121aa0d47..474fcbd27 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -5,6 +5,7 @@ import Combine import SessionSnodeKit import SessionUtilitiesKit +// FIXME: Remove this once legacy notifications and legacy groups are deprecated public enum NotifyPushServerJob: JobExecutor { public static var maxFailureCount: Int = 20 public static var requiresThreadId: Bool = false @@ -26,7 +27,7 @@ public enum NotifyPushServerJob: JobExecutor { } PushNotificationAPI - .notify( + .legacyNotify( recipient: details.message.recipient, with: details.message.data, maxRetryCount: 4 diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index 5bbccaf02..211d5b3da 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -89,7 +89,7 @@ extension OpenGroupAPI.Message { throw HTTPError.parsingFailed } - case .none: + case .none, .group: SNLog("Ignoring message with invalid sender.") throw HTTPError.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 3716460df..2b006dadf 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -992,6 +992,8 @@ public final class OpenGroupManager { .filter(possibleKeys.contains(GroupMember.Columns.profileId)) .filter(targetRoles.contains(GroupMember.Columns.role)) .isNotEmpty(db) + + case .group: return false } } .defaulting(to: false) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index 860df5069..2bfa9e813 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -192,8 +192,13 @@ extension MessageReceiver { // Start polling ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey) - // Notify the PN server - let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey(db)) + // Subscribe for push notifications + PushNotificationAPI + .subscribeToLegacyGroup( + legacyGroupId: groupPublicKey, + currentUserPublicKey: getUserHexEncodedPublicKey(db) + ) + .sinkUntilComplete() } /// Extracts and adds the new encryption key pair to our list of key pairs if there is one for our public key, AND the message was @@ -479,11 +484,12 @@ extension MessageReceiver { .keyPairs .deleteAll(db) - let _ = PushNotificationAPI.performOperation( - .unsubscribe, - for: threadId, - publicKey: userPublicKey - ) + PushNotificationAPI + .unsubscribeFromLegacyGroup( + legacyGroupId: threadId, + currentUserPublicKey: userPublicKey + ) + .sinkUntilComplete() } } ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index a891bb3ba..943f3f556 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -71,7 +71,7 @@ extension MessageReceiver { return try? OpenGroup.fetchOne(db, id: threadId) }() - let variant: Interaction.Variant = { + let variant: Interaction.Variant = try { guard let senderSessionId: SessionId = SessionId(from: sender), let openGroup: OpenGroup = maybeOpenGroup @@ -106,6 +106,10 @@ extension MessageReceiver { .standardOutgoing : .standardIncoming ) + + case .group: + SNLog("Ignoring message with invalid sender.") + throw HTTPError.parsingFailed } }() diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index a6722a847..9f4ed0dc2 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -117,11 +117,10 @@ extension MessageSender { memberSendData .map { MessageSender.sendImmediate(preparedSendData: $0) } .appending( - // Notify the PN server - PushNotificationAPI.performOperation( - .subscribe, - for: groupPublicKey, - publicKey: userPublicKey + // Subscribe for push notifications (if enabled) + PushNotificationAPI.subscribeToLegacyGroup( + legacyGroupId: groupPublicKey, + currentUserPublicKey: userPublicKey ) ) ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index d4ab2e3c7..8f3c0c4f5 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -64,6 +64,11 @@ public enum MessageReceiver { userEd25519KeyPair: userEd25519KeyPair, using: dependencies ) + + case .group: + // TODO: Need to decide how we will handle updated group messages + SNLog("Ignoring message with invalid sender.") + throw HTTPError.parsingFailed } case .closedGroupMessage: diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupRequest.swift new file mode 100644 index 000000000..962011dfd --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupRequest.swift @@ -0,0 +1,10 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct LegacyGroupRequest: Codable { + let pubKey: String + let closedGroupPublicKey: String + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyNotifyRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyNotifyRequest.swift new file mode 100644 index 000000000..491fa7757 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyNotifyRequest.swift @@ -0,0 +1,15 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct LegacyNotifyRequest: Codable { + enum CodingKeys: String, CodingKey { + case data + case sendTo = "send_to" + } + + let data: String + let sendTo: String + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyPushServerResponse.swift similarity index 78% rename from SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift rename to SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyPushServerResponse.swift index eee22e266..dc8c77ff7 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushServerResponse.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyPushServerResponse.swift @@ -3,7 +3,7 @@ import Foundation extension PushNotificationAPI { - struct PushServerResponse: Codable { + struct LegacyPushServerResponse: Codable { let code: Int let message: String? } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushNotificationAPIRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushNotificationAPIRequest.swift new file mode 100644 index 000000000..671b0b7a5 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/PushNotificationAPIRequest.swift @@ -0,0 +1,33 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public struct PushNotificationAPIRequest: Encodable { + private enum CodingKeys: String, CodingKey { + case method + case body = "params" + } + + internal let endpoint: PushNotificationAPI.Endpoint + internal let body: T + + // MARK: - Initialization + + public init( + endpoint: PushNotificationAPI.Endpoint, + body: T + ) { + self.endpoint = endpoint + self.body = body + } + + // MARK: - Codable + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(endpoint.rawValue, forKey: .method) + try container.encode(body, forKey: .body) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift new file mode 100644 index 000000000..6c302a051 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift @@ -0,0 +1,153 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit + +extension PushNotificationAPI { + struct SubscribeRequest: Encodable { + struct ServiceInfo: Codable { + private enum CodingKeys: String, CodingKey { + case token + } + + private let token: String + + // MARK: - Initialization + + init(token: String) { + self.token = token + } + } + + private enum CodingKeys: String, CodingKey { + case pubkey + case ed25519PublicKey = "session_ed25519" + case subkey = "subkey_tag" + case namespaces + case includeMessageData = "data" + case timestamp = "sig_ts" + case signatureBase64 = "signature" + case service + case serviceInfo = "service_info" + case notificationsEncryptionKey = "enc_key" + } + + /// The 33-byte account being subscribed to; typically a session ID. + private let pubkey: String + + /// List of integer namespace (-32768 through 32767). These must be sorted in ascending order. + private let namespaces: [SnodeAPI.Namespace] + + /// If provided and true then notifications will include the body of the message (as long as it isn't too large); if false then the body will + /// not be included in notifications. + private let includeMessageData: Bool + + /// Dict of service-specific data; typically this includes just a "token" field with a device-specific token, but different services in the + /// future may have different input requirements. + private let serviceInfo: ServiceInfo + + /// 32-byte encryption key; notification payloads sent to the device will be encrypted with XChaCha20-Poly1305 using this key. Though + /// it is permitted for this to change, it is recommended that the device generate this once and persist it. + private let notificationsEncryptionKey: Data + + /// 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth + private let subkey: String? + + /// The signature unix timestamp (seconds, not ms) + private let timestamp: TimeInterval + + /// When the pubkey value starts with 05 (i.e. a session ID) this is the underlying ed25519 32-byte pubkey associated with the session + /// ID. When not 05, this field should not be provided. + private let ed25519PublicKey: [UInt8] + + /// Secret key used to generate the signature (**Not** sent with the request) + private let ed25519SecretKey: [UInt8] + + // MARK: - Initialization + + init( + pubkey: String, + namespaces: [SnodeAPI.Namespace], + includeMessageData: Bool, + serviceInfo: ServiceInfo, + notificationsEncryptionKey: Data, + subkey: String?, + timestamp: TimeInterval, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.pubkey = pubkey + self.namespaces = namespaces + self.includeMessageData = includeMessageData + self.serviceInfo = serviceInfo + self.notificationsEncryptionKey = notificationsEncryptionKey + self.subkey = subkey + self.timestamp = timestamp + self.ed25519PublicKey = ed25519PublicKey + self.ed25519SecretKey = ed25519SecretKey + } + + // MARK: - Coding + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + // Generate the signature for the request for encoding + let signatureBase64: String = try generateSignature().toBase64() + try container.encode(pubkey, forKey: .pubkey) + try container.encode(ed25519PublicKey.toHexString(), forKey: .ed25519PublicKey) + try container.encodeIfPresent(subkey, forKey: .subkey) + try container.encode(namespaces.map { $0.rawValue}.sorted(), forKey: .namespaces) + try container.encode(includeMessageData, forKey: .includeMessageData) + try container.encode(Int64(timestamp), forKey: .timestamp) // Server expects rounded seconds + try container.encode(signatureBase64, forKey: .signatureBase64) + try container.encode(Service.apns, forKey: .service) + try container.encode(serviceInfo, forKey: .serviceInfo) + try container.encode(notificationsEncryptionKey.toHexString(), forKey: .notificationsEncryptionKey) + } + + // MARK: - Abstract Methods + + func generateSignature() throws -> [UInt8] { + /// The signature data collected and stored here is used by the PN server to subscribe to the swarms + /// for the given account; the specific rules are governed by the storage server, but in general: + /// + /// A signature must have been produced (via the timestamp) within the past 14 days. It is + /// recommended that clients generate a new signature whenever they re-subscribe, and that + /// re-subscriptions happen more frequently than once every 14 days. + /// + /// A signature is signed using the account's Ed25519 private key (or Ed25519 subkey, if using + /// subkey authentication with a `subkey_tag`, for future closed group subscriptions), and signs the value: + /// `"MONITOR" || HEX(ACCOUNT) || SIG_TS || DATA01 || NS[0] || "," || ... || "," || NS[n]` + /// + /// Where `SIG_TS` is the `sig_ts` value as a base-10 string; `DATA01` is either "0" or "1" depending + /// on whether the subscription wants message data included; and the trailing `NS[i]` values are a + /// comma-delimited list of namespaces that should be subscribed to, in the same sorted order as + /// the `namespaces` parameter. + let verificationBytes: [UInt8] = "MONITOR".bytes + .appending(contentsOf: pubkey.bytes) + .appending(contentsOf: "\(Int64(timestamp))".bytes) // Server expects rounded seconds + .appending(contentsOf: (includeMessageData ? "1" : "0").bytes) + .appending( + contentsOf: namespaces + .map { $0.rawValue } // Intentionally not using `verificationString` here + .sorted() + .map { "\($0)" } + .joined(separator: ",") + .bytes + ) + + // TODO: Need to add handling for subkey auth + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeResponse.swift new file mode 100644 index 000000000..c2298e1c2 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeResponse.swift @@ -0,0 +1,31 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct SubscribeResponse: Codable { + /// Flag indicating the success of the registration + let success: Bool? + + /// Value is `true` upon an initial registration + let added: Bool? + + /// Value is `true` upon a renewal/update registration + let updated: Bool? + + /// This will be one of the errors found here: + /// https://github.com/jagerman/session-push-notification-server/blob/spns-v2/spns/hive/subscription.hpp#L21 + /// + /// Values at the time of writing are: + /// OK = 0 // Great Success! + /// BAD_INPUT = 1 // Unparseable, invalid values, missing required arguments, etc. (details in the string) + /// SERVICE_NOT_AVAILABLE = 2 // The requested service name isn't currently available + /// SERVICE_TIMEOUT = 3 // The backend service did not response + /// ERROR = 4 // There was some other error processing the subscription (details in the string) + /// INTERNAL_ERROR = 5 // An internal program error occured processing the request + let error: Int? + + /// Includes additional information about the error + let message: String? + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift new file mode 100644 index 000000000..e26007fee --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift @@ -0,0 +1,111 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit + +extension PushNotificationAPI { + struct UnsubscribeRequest: Encodable { + struct ServiceInfo: Codable { + private enum CodingKeys: String, CodingKey { + case token + } + + private let token: String + + // MARK: - Initialization + + init(token: String) { + self.token = token + } + } + + private enum CodingKeys: String, CodingKey { + case pubkey + case ed25519PublicKey = "session_ed25519" + case subkey = "subkey_tag" + case timestamp = "sig_ts" + case signatureBase64 = "signature" + case service + case serviceInfo = "service_info" + } + + /// The 33-byte account being subscribed to; typically a session ID. + private let pubkey: String + + /// Dict of service-specific data; typically this includes just a "token" field with a device-specific token, but different services in the + /// future may have different input requirements. + private let serviceInfo: ServiceInfo + + /// 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth + private let subkey: String? + + /// The signature unix timestamp (seconds, not ms) + private let timestamp: TimeInterval + + /// When the pubkey value starts with 05 (i.e. a session ID) this is the underlying ed25519 32-byte pubkey associated with the session + /// ID. When not 05, this field should not be provided. + private let ed25519PublicKey: [UInt8] + + /// Secret key used to generate the signature (**Not** sent with the request) + private let ed25519SecretKey: [UInt8] + + // MARK: - Initialization + + init( + pubkey: String, + serviceInfo: ServiceInfo, + subkey: String?, + timestamp: TimeInterval, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.pubkey = pubkey + self.serviceInfo = serviceInfo + self.subkey = subkey + self.timestamp = timestamp + self.ed25519PublicKey = ed25519PublicKey + self.ed25519SecretKey = ed25519SecretKey + } + + // MARK: - Coding + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + // Generate the signature for the request for encoding + let signatureBase64: String = try generateSignature().toBase64() + try container.encode(pubkey, forKey: .pubkey) + try container.encode(ed25519PublicKey.toHexString(), forKey: .ed25519PublicKey) + try container.encodeIfPresent(subkey, forKey: .subkey) + try container.encode(timestamp, forKey: .timestamp) + try container.encode(signatureBase64, forKey: .signatureBase64) + try container.encode(Service.apns, forKey: .service) + try container.encode(serviceInfo, forKey: .serviceInfo) + } + + // MARK: - Abstract Methods + + func generateSignature() throws -> [UInt8] { + /// A signature is signed using the account's Ed25519 private key (or Ed25519 subkey, if using + /// subkey authentication with a `subkey_tag`, for future closed group subscriptions), and signs the value: + /// `"UNSUBSCRIBE" || HEX(ACCOUNT) || SIG_TS` + /// + /// Where `SIG_TS` is the `sig_ts` value as a base-10 string and must be within 24 hours of the current time. + let verificationBytes: [UInt8] = "UNSUBSCRIBE".bytes + .appending(contentsOf: pubkey.bytes) + .appending(contentsOf: "\(timestamp)".data(using: .ascii)?.bytes) + + // TODO: Need to add handling for subkey auth + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeResponse.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeResponse.swift new file mode 100644 index 000000000..03b38c524 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeResponse.swift @@ -0,0 +1,31 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct UnsubscribeResponse: Codable { + /// Flag indicating the success of the registration + let success: Bool? + + /// Value is `true` upon an initial registration + let added: Bool? + + /// Value is `true` upon a renewal/update registration + let updated: Bool? + + /// This will be one of the errors found here: + /// https://github.com/jagerman/session-push-notification-server/blob/spns-v2/spns/hive/subscription.hpp#L21 + /// + /// Values at the time of writing are: + /// OK = 0 // Great Success! + /// BAD_INPUT = 1 // Unparseable, invalid values, missing required arguments, etc. (details in the string) + /// SERVICE_NOT_AVAILABLE = 2 // The requested service name isn't currently available + /// SERVICE_TIMEOUT = 3 // The backend service did not response + /// ERROR = 4 // There was some other error processing the subscription (details in the string) + /// INTERNAL_ERROR = 5 // An internal program error occured processing the request + let error: Int? + + /// Includes additional information about the error + let message: String? + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 78886b8c8..4d1c0148a 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -3,134 +3,31 @@ import Foundation import Combine import GRDB +import Sodium import SessionSnodeKit import SessionUtilitiesKit public enum PushNotificationAPI { - struct RegistrationRequestBody: Codable { - let token: String - let pubKey: String? - } - - struct NotifyRequestBody: Codable { - enum CodingKeys: String, CodingKey { - case data - case sendTo = "send_to" - } - - let data: String - let sendTo: String - } - - struct ClosedGroupRequestBody: Codable { - let closedGroupPublicKey: String - let pubKey: String - } - - // MARK: - Settings - - public static let server = "https://live.apns.getsession.org" - public static let serverPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049" - + internal static let sodium: Atomic = Atomic(Sodium()) + private static let keychainService: String = "PNKeyChainService" + private static let encryptionKeyKey: String = "PNEncryptionKeyKey" + private static let encryptionKeyLength: Int = 32 private static let maxRetryCount: Int = 4 - private static let tokenExpirationInterval: TimeInterval = 12 * 60 * 60 - - public enum ClosedGroupOperation: Int { - case subscribe, unsubscribe - - public var endpoint: String { - switch self { - case .subscribe: return "subscribe_closed_group" - case .unsubscribe: return "unsubscribe_closed_group" - } - } - } + private static let tokenExpirationInterval: TimeInterval = (12 * 60 * 60) - // MARK: - Registration + public static let server = "https://push.getsession.org" + public static let serverPublicKey = "d7557fe563e2610de876c0ac7341b62f3c82d5eea4b62c702392ea4368f51b3b" + public static let legacyServer = "https://live.apns.getsession.org" + public static let legacyServerPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049" + + // MARK: - Requests - public static func unregister(_ token: Data) -> AnyPublisher { - let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: token.toHexString(), pubKey: nil) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Fail(error: HTTPError.invalidJSON) - .eraseToAnyPublisher() - } - - // Unsubscribe from all closed groups (including ones the user is no longer a member of, - // just in case) - Storage.shared - .readPublisher { db -> (String, Set) in - ( - getUserHexEncodedPublicKey(db), - try ClosedGroup - .select(.threadId) - .asRequest(of: String.self) - .fetchSet(db) - ) - } - .flatMap { userPublicKey, closedGroupPublicKeys in - Publishers - .MergeMany( - closedGroupPublicKeys - .map { closedGroupPublicKey -> AnyPublisher in - PushNotificationAPI - .performOperation( - .unsubscribe, - for: closedGroupPublicKey, - publicKey: userPublicKey - ) - } - ) - .collect() - .eraseToAnyPublisher() - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete() - - // Unregister for normal push notifications - let url = URL(string: "\(server)/unregister")! - var request: URLRequest = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] - request.httpBody = body - - return OnionRequestAPI - .sendOnionRequest(request, to: server, with: serverPublicKey) - .map { _, data -> Void in - guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { - return SNLog("Couldn't unregister from push notifications.") - } - guard response.code != 0 else { - return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") - } - - return () - } - .retry(maxRetryCount) - .handleEvents( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: SNLog("Couldn't unregister from push notifications.") - } - } - ) - .eraseToAnyPublisher() - } - - public static func register( - with token: Data, - publicKey: String, - isForcedUpdate: Bool + public static func subscribe( + token: Data, + isForcedUpdate: Bool, + using dependencies: SSKDependencies = SSKDependencies() ) -> AnyPublisher { let hexEncodedToken: String = token.toHexString() - let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: hexEncodedToken, pubKey: publicKey) - - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Fail(error: HTTPError.invalidJSON) - .eraseToAnyPublisher() - } - let oldToken: String? = UserDefaults.standard[.deviceToken] let lastUploadTime: Double = UserDefaults.standard[.lastDeviceTokenUpload] let now: TimeInterval = Date().timeIntervalSince1970 @@ -142,153 +39,402 @@ public enum PushNotificationAPI { .eraseToAnyPublisher() } - let url = URL(string: "\(server)/register")! - var request: URLRequest = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] - request.httpBody = body + guard let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey() else { + SNLog("Unable to retrieve PN encryption key.") + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } - return Publishers - .MergeMany( - [ - OnionRequestAPI - .sendOnionRequest(request, to: server, with: serverPublicKey) - .map { _, data -> Void in - guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { - return SNLog("Couldn't register device token.") + // TODO: Need to generate requests for each updated group as well + return Storage.shared + .readPublisher { db -> (SubscribeRequest, String, Set) in + guard let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { + throw SnodeAPIError.noKeyPair + } + + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] + .defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType) + + let request: SubscribeRequest = SubscribeRequest( + pubkey: currentUserPublicKey, + namespaces: [.default], + includeMessageData: (previewType == .nameAndPreview), // TODO: Test resubscribing when changing the type + serviceInfo: SubscribeRequest.ServiceInfo( + token: hexEncodedToken + ), + notificationsEncryptionKey: notificationsEncryptionKey, + subkey: nil, + timestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000), // Seconds + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + + return ( + request, + currentUserPublicKey, + try ClosedGroup + .select(.threadId) + .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + ) + .asRequest(of: String.self) + .fetchSet(db) + ) + } + .flatMap { request, currentUserPublicKey, legacyGroupIds -> AnyPublisher in + Publishers + .MergeMany( + [ + PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .subscribe, + body: request + ) + ) + .decoded(as: SubscribeResponse.self, using: dependencies) + .retry(maxRetryCount) + .handleEvents( + receiveOutput: { _, response in + guard response.success == true else { + return SNLog("Couldn't subscribe for push notifications due to error (\(response.error ?? -1)): \(response.message ?? "nil").") + } + + UserDefaults.standard[.deviceToken] = hexEncodedToken + UserDefaults.standard[.lastDeviceTokenUpload] = now + UserDefaults.standard[.isUsingFullAPNs] = true + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't subscribe for push notifications.") + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + ].appending( + // FIXME: Remove this once legacy groups are deprecated + contentsOf: legacyGroupIds + .map { legacyGroupId in + PushNotificationAPI.subscribeToLegacyGroup( + legacyGroupId: legacyGroupId, + currentUserPublicKey: currentUserPublicKey, + using: dependencies + ) + } + ) + ) + .collect() + .map { _ in () } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + public static func unsubscribe( + token: Data, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher { + let hexEncodedToken: String = token.toHexString() + + // FIXME: Remove this once legacy groups are deprecated + /// Unsubscribe from all legacy groups (including ones the user is no longer a member of, just in case) + Storage.shared + .readPublisher { db -> (String, Set) in + ( + getUserHexEncodedPublicKey(db), + try ClosedGroup + .select(.threadId) + .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .asRequest(of: String.self) + .fetchSet(db) + ) + } + .flatMap { currentUserPublicKey, legacyGroupIds in + Publishers + .MergeMany( + legacyGroupIds + .map { legacyGroupId -> AnyPublisher in + PushNotificationAPI + .unsubscribeFromLegacyGroup( + legacyGroupId: legacyGroupId, + currentUserPublicKey: currentUserPublicKey, + using: dependencies + ) } - guard response.code != 0 else { - return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").") + ) + .collect() + .eraseToAnyPublisher() + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() + + // TODO: Need to generate requests for each updated group as well + return Storage.shared + .readPublisher { db -> UnsubscribeRequest in + guard let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { + throw SnodeAPIError.noKeyPair + } + + return UnsubscribeRequest( + pubkey: getUserHexEncodedPublicKey(db), + serviceInfo: UnsubscribeRequest.ServiceInfo( + token: hexEncodedToken + ), + subkey: nil, + timestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000), // Seconds + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + } + .flatMap { request -> AnyPublisher in + PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .unsubscribe, + body: request + ) + ) + .decoded(as: UnsubscribeResponse.self, using: dependencies) + .retry(maxRetryCount) + .handleEvents( + receiveOutput: { _, response in + guard response.success == true else { + return SNLog("Couldn't unsubscribe for push notifications due to error (\(response.error ?? -1)): \(response.message ?? "nil").") } - UserDefaults.standard[.deviceToken] = hexEncodedToken - UserDefaults.standard[.lastDeviceTokenUpload] = now - UserDefaults.standard[.isUsingFullAPNs] = true - return () - } - .retry(maxRetryCount) - .handleEvents( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: SNLog("Couldn't register device token.") - } + UserDefaults.standard[.deviceToken] = nil + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't unsubscribe for push notifications.") } - ) - .eraseToAnyPublisher() - ].appending( - contentsOf: Storage.shared - .read { db -> [String] in - try ClosedGroup - .select(.threadId) - .joining( - required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) - ) - .asRequest(of: String.self) - .fetchAll(db) - } - .defaulting(to: []) - .map { closedGroupPublicKey -> AnyPublisher in - PushNotificationAPI - .performOperation( - .subscribe, - for: closedGroupPublicKey, - publicKey: publicKey - ) } + ) + .map { _ in () } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + // MARK: - Legacy Notifications + + // FIXME: Remove this once legacy notifications and legacy groups are deprecated + public static func legacyNotify( + recipient: String, + with message: String, + maxRetryCount: Int? = nil, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher { + return PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .legacyNotify, + body: LegacyNotifyRequest( + data: message, + sendTo: recipient + ) ) ) - .collect() + .decoded(as: LegacyPushServerResponse.self, using: dependencies) + .retry(maxRetryCount ?? PushNotificationAPI.maxRetryCount) + .handleEvents( + receiveOutput: { _, response in + guard response.code != 0 else { + return SNLog("Couldn't send push notification due to error: \(response.message ?? "nil").") + } + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't send push notification.") + } + } + ) .map { _ in () } .eraseToAnyPublisher() } - - public static func performOperation( - _ operation: ClosedGroupOperation, - for closedGroupPublicKey: String, - publicKey: String + + // MARK: - Legacy Groups + + // FIXME: Remove this once legacy groups are deprecated + public static func subscribeToLegacyGroup( + legacyGroupId: String, + currentUserPublicKey: String, + using dependencies: SSKDependencies = SSKDependencies() ) -> AnyPublisher { let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs] - let requestBody: ClosedGroupRequestBody = ClosedGroupRequestBody( - closedGroupPublicKey: closedGroupPublicKey, - pubKey: publicKey - ) guard isUsingFullAPNs else { return Just(()) .setFailureType(to: Error.self) .eraseToAnyPublisher() } - guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Fail(error: HTTPError.invalidJSON) - .eraseToAnyPublisher() - } - let url = URL(string: "\(server)/\(operation.endpoint)")! - var request: URLRequest = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] - request.httpBody = body - - return OnionRequestAPI - .sendOnionRequest(request, to: server, with: serverPublicKey) - .map { _, data in - guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") - } - guard response.code != 0 else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") - } - - return () - } + return PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .legacyGroupSubscribe, + body: LegacyGroupRequest( + pubKey: currentUserPublicKey, + closedGroupPublicKey: legacyGroupId + ) + ) + ) + .decoded(as: LegacyPushServerResponse.self, using: dependencies) .retry(maxRetryCount) .handleEvents( + receiveOutput: { _, response in + guard response.code != 0 else { + return SNLog("Couldn't subscribe for legacy group: \(legacyGroupId) due to error: \(response.message ?? "nil").") + } + }, receiveCompletion: { result in switch result { case .finished: break - case .failure: - SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") + case .failure: SNLog("Couldn't subscribe for legacy group: \(legacyGroupId).") } } ) + .map { _ in () } .eraseToAnyPublisher() } - // MARK: - Notify - - public static func notify( - recipient: String, - with message: String, - maxRetryCount: Int? = nil + // FIXME: Remove this once legacy groups are deprecated + public static func unsubscribeFromLegacyGroup( + legacyGroupId: String, + currentUserPublicKey: String, + using dependencies: SSKDependencies = SSKDependencies() ) -> AnyPublisher { - let requestBody: NotifyRequestBody = NotifyRequestBody(data: message, sendTo: recipient) + let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs] - guard let body: Data = try? JSONEncoder().encode(requestBody) else { + // TODO: Need to validate if this is actually desired behaviour - would this check prevent the app from unsubscribing if the user switches off fast mode??? (this is what the app is currently doing) + // TODO: This flag seems like it might actually be buggy... should double check it + guard isUsingFullAPNs else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .legacyGroupUnsubscribe, + body: LegacyGroupRequest( + pubKey: currentUserPublicKey, + closedGroupPublicKey: legacyGroupId + ) + ) + ) + .decoded(as: LegacyPushServerResponse.self, using: dependencies) + .retry(maxRetryCount) + .handleEvents( + receiveOutput: { _, response in + guard response.code != 0 else { + return SNLog("Couldn't unsubscribe for legacy group: \(legacyGroupId) due to error: \(response.message ?? "nil").") + } + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't unsubscribe for legacy group: \(legacyGroupId).") + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + } + + // MARK: - Security + + @discardableResult private static func getOrGenerateEncryptionKey() throws -> Data { + // TODO: May want to work this differently (will break after a phone restart if the device hasn't been unlocked yet) + do { + var encryptionKey: Data = try SSKDefaultKeychainStorage.shared.data( + forService: keychainService, + key: encryptionKeyKey + ) + defer { encryptionKey.resetBytes(in: 0..( + request: PushNotificationAPIRequest, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + guard + let url: URL = URL(string: "\(request.endpoint.server)/\(request.endpoint.rawValue)"), + let payload: Data = try? JSONEncoder().encode(request.body) + else { return Fail(error: HTTPError.invalidJSON) .eraseToAnyPublisher() } - let url = URL(string: "\(server)/notify")! - var request: URLRequest = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] - request.httpBody = body + guard Features.useOnionRequests else { + return HTTP + .execute( + .post, + "\(request.endpoint.server)/\(request.endpoint.rawValue)", + body: payload + ) + .map { response in (HTTP.ResponseInfo(code: -1, headers: [:]), response) } + .eraseToAnyPublisher() + } - return OnionRequestAPI - .sendOnionRequest(request, to: server, with: serverPublicKey) - .map { _, data -> Void in - guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { - return SNLog("Couldn't send push notification.") - } - guard response.code != 0 else { - return SNLog("Couldn't send push notification due to error: \(response.message ?? "nil").") - } - - return () - } - .retry(maxRetryCount ?? PushNotificationAPI.maxRetryCount) + var urlRequest: URLRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] + urlRequest.httpBody = payload + + return dependencies.onionApi + .sendOnionRequest(urlRequest, to: request.endpoint.server, with: request.endpoint.serverPublicKey) .eraseToAnyPublisher() } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift new file mode 100644 index 000000000..cc3068395 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift @@ -0,0 +1,40 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension PushNotificationAPI { + enum Endpoint: String { + case subscribe = "subscribe" + case unsubscribe = "unsubscribe" + + // MARK: - Legacy Endpoints + + case legacyNotify = "notify" + case legacyRegister = "register" + case legacyUnregister = "unregister" + case legacyGroupSubscribe = "subscribe_closed_group" + case legacyGroupUnsubscribe = "unsubscribe_closed_group" + + // MARK: - Convenience + + var server: String { + switch self { + case .legacyNotify, .legacyRegister, .legacyUnregister, + .legacyGroupSubscribe, .legacyGroupUnsubscribe: + return PushNotificationAPI.legacyServer + + default: return PushNotificationAPI.server + } + } + + var serverPublicKey: String { + switch self { + case .legacyNotify, .legacyRegister, .legacyUnregister, + .legacyGroupSubscribe, .legacyGroupUnsubscribe: + return PushNotificationAPI.legacyServerPublicKey + + default: return PushNotificationAPI.serverPublicKey + } + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift new file mode 100644 index 000000000..cc2e5157f --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift @@ -0,0 +1,9 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + enum Service: String, Codable { + case apns + } +} diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index d8a1cfde4..2d95ab044 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -8,7 +8,7 @@ import SessionUIKit import SignalCoreKit final class ShareNavController: UINavigationController, ShareViewDelegate { - private var areVersionMigrationsComplete = false + private static let areVersionMigrationsComplete: Atomic = Atomic(false) public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>? // MARK: - Error @@ -24,6 +24,8 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { override func loadView() { super.loadView() + + view.themeBackgroundColor = .backgroundPrimary // This should be the first thing we do (Note: If you leave the share context and return to it // the context will already exist, trying to override it results in the share context crashing @@ -72,6 +74,12 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { name: .OWSApplicationDidEnterBackground, object: nil ) + + /// **Note:** If the user opens, dismisses and re-opens the share extension it'll actually use the same instance which + /// results in the `AppSetup` not actually running (and the UI not actually being loaded correctly) - in order to avoid this + /// we call `checkIsAppReady` explicitly here assuming that either the `AppSetup` _hasn't_ complete or won't ever + /// get run + checkIsAppReady() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -88,7 +96,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { Logger.debug("") - areVersionMigrationsComplete = true + ShareNavController.areVersionMigrationsComplete.mutate { $0 = true } // If we need a config sync then trigger it now if needsConfigSync { @@ -105,10 +113,15 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { AssertIsOnMainThread() // App isn't ready until storage is ready AND all version migrations are complete. - guard areVersionMigrationsComplete else { return } - guard Storage.shared.isValid else { return } + guard ShareNavController.areVersionMigrationsComplete.wrappedValue else { return } + guard Storage.shared.isValid else { + // If the database is invalid then the UI will handle it + showLockScreenOrMainContent() + return + } guard !AppReadiness.isAppReady() else { // Only mark the app as ready once. + showLockScreenOrMainContent() return } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index a00a4c020..5b0f4e730 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -31,6 +31,18 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView return titleLabel }() + + private lazy var databaseErrorLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.mediumFontSize) + result.text = "database_inaccessible_error".localized() + result.textAlignment = .center + result.themeTextColor = .textPrimary + result.numberOfLines = 0 + result.isHidden = true + + return result + }() private lazy var tableView: UITableView = { let tableView: UITableView = UITableView() @@ -53,6 +65,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView view.themeBackgroundColor = .backgroundPrimary view.addSubview(tableView) + view.addSubview(databaseErrorLabel) setupLayout() @@ -99,6 +112,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView private func setupLayout() { tableView.pin(to: view) + + databaseErrorLabel.pin(.top, to: .top, of: view, withInset: Values.massiveSpacing) + databaseErrorLabel.pin(.leading, to: .leading, of: view, withInset: Values.veryLargeSpacing) + databaseErrorLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.veryLargeSpacing) } // MARK: - Updating @@ -107,7 +124,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // Start observing for data changes dataChangeObservable = Storage.shared.start( viewModel.observableViewData, - onError: { _ in }, + onError: { [weak self] _ in self?.databaseErrorLabel.isHidden = Storage.shared.isValid }, onChange: { [weak self] viewData in // The defaul scheduler emits changes on the main thread self?.handleUpdates(viewData) diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 4993be3f7..7679942f8 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -56,20 +56,25 @@ open class Storage { return } - // Generate the database KeySpec if needed (this MUST be done before we try to access the database - // as a different thread might attempt to access the database before the key is successfully created) - // - // Note: We reset the bytes immediately after generation to ensure the database key doesn't hang - // around in memory unintentionally - var tmpKeySpec: Data = Storage.getOrGenerateDatabaseKeySpec() - tmpKeySpec.resetBytes(in: 0.. Data { + @discardableResult private static func getOrGenerateDatabaseKeySpec() throws -> Data { do { var keySpec: Data = try getDatabaseCipherKeySpec() defer { keySpec.resetBytes(in: 0.. Void, onChange: @escaping (Reducer.Value) -> Void ) -> DatabaseCancellable { - guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return AnyDatabaseCancellable(cancel: {}) } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { + onError(StorageError.databaseInvalid) + return AnyDatabaseCancellable(cancel: {}) + } return observation.start( in: dbWriter, diff --git a/SessionUtilitiesKit/Database/StorageError.swift b/SessionUtilitiesKit/Database/StorageError.swift index 7e48cabc5..c4036b8d6 100644 --- a/SessionUtilitiesKit/Database/StorageError.swift +++ b/SessionUtilitiesKit/Database/StorageError.swift @@ -7,6 +7,8 @@ public enum StorageError: Error { case databaseInvalid case migrationFailed case invalidKeySpec + case keySpecCreationFailed + case keySpecInaccessible case decodingFailed case failedToSave diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift index c9391c6c7..7a8dba7b9 100644 --- a/SessionUtilitiesKit/General/SessionId.swift +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -10,6 +10,7 @@ public struct SessionId { case standard = "05" // Used for identified users, open groups, etc. case blinded = "15" // Used for authentication and participants in open groups with blinding enabled case unblinded = "00" // Used for authentication in open groups with blinding disabled + case group = "03" // Used for update group conversations public init?(from stringValue: String?) { guard let stringValue: String = stringValue else { return nil } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 9e89ed175..0ff6acf26 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -63,6 +63,14 @@ public enum AppSetup { migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, migrationsCompletion: @escaping (Result, Bool) -> () ) { + // If the database can't be initialised into a valid state then error + guard Storage.shared.isValid else { + DispatchQueue.main.async { + migrationsCompletion(Result.failure(StorageError.databaseInvalid), false) + } + return + } + var backgroundTask: OWSBackgroundTask? = (backgroundTask ?? OWSBackgroundTask(labelStr: #function)) Storage.shared.perform( From 61ad85b97b16b6a6dc627205cc4dce5be839fd72 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 22 May 2023 10:57:16 +1000 Subject: [PATCH 04/50] Added logic to unsubscribe for legacy one-to-one PNs --- .../Models/LegacyUnsubscribeRequest.swift | 14 +++++++ .../Notifications/PushNotificationAPI.swift | 37 ++++++++++++++++++- .../General/SNUserDefaults.swift | 1 + 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyUnsubscribeRequest.swift diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyUnsubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyUnsubscribeRequest.swift new file mode 100644 index 000000000..663bafb17 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyUnsubscribeRequest.swift @@ -0,0 +1,14 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit + +extension PushNotificationAPI { + struct LegacyUnsubscribeRequest: Codable { + private let token: String + + init(token: String) { + self.token = token + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 4d1c0148a..bb5d6ce8d 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -115,7 +115,42 @@ public enum PushNotificationAPI { } } ) - .map { _ in () } + .flatMap { _ in + guard UserDefaults.standard[.hasUnregisteredForLegacyPushNotifications] != true else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .legacyUnregister, + body: LegacyUnsubscribeRequest( + token: hexEncodedToken + ) + ) + ) + .retry(maxRetryCount) + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: + /// Save that we've already unsubscribed + /// + /// **Note:** The server can return an error (`response.code != 0`) but + /// that means the server properly processed the request and the error is likely + /// due to the device not actually being previously subscribed for notifications + /// rather than actually failing to unsubscribe + UserDefaults.standard[.hasUnregisteredForLegacyPushNotifications] = true + + case .failure: SNLog("Couldn't unsubscribe for legacy notifications.") + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + } .eraseToAnyPublisher() ].appending( // FIXME: Remove this once legacy groups are deprecated diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index 82f18bd64..910461a3d 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -31,6 +31,7 @@ public enum SNUserDefaults { case hasSeenCallIPExposureWarning case hasSeenCallMissedTips case isUsingFullAPNs + case hasUnregisteredForLegacyPushNotifications case wasUnlinked case isMainAppActive case isCallOngoing From fc94d24ddfff250473d553db6901250a6f9a9d76 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 23 May 2023 17:38:54 +1000 Subject: [PATCH 05/50] Fixed up and added more unit tests to the JobRunner --- .../Models/ControlMessageProcessRecord.swift | 2 +- .../General/Dependencies.swift | 10 +- SessionUtilitiesKit/JobRunner/JobRunner.swift | 78 +- .../JobRunner/JobRunnerSpec.swift | 836 ++++++++++++------ 4 files changed, 643 insertions(+), 283 deletions(-) diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift index bc01ef67c..709e97f0e 100644 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -151,7 +151,7 @@ internal extension ControlMessageProcessRecord { .infoClosedGroupCreated: return nil - case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving: + case .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving: self.variant = .closedGroupControlMessage case .infoDisappearingMessagesUpdate: diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift index 61e2f510d..6a37e8475 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -40,6 +40,12 @@ open class Dependencies { set { _date.mutate { $0 = newValue } } } + public var _fixedTime: Atomic + public var fixedTime: Int { + get { Dependencies.getValueSettingIfNull(&_fixedTime) { 0 } } + set { _fixedTime.mutate { $0 = newValue } } + } + // MARK: - Initialization public init( @@ -48,7 +54,8 @@ open class Dependencies { jobRunner: JobRunnerType? = nil, scheduler: ValueObservationScheduler? = nil, standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil + date: Date? = nil, + fixedTime: Int? = nil ) { _generalCache = Atomic(generalCache) _storage = Atomic(storage) @@ -56,6 +63,7 @@ open class Dependencies { _scheduler = Atomic(scheduler) _standardUserDefaults = Atomic(standardUserDefaults) _date = Atomic(date) + _fixedTime = Atomic(fixedTime) } // MARK: - Convenience diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 96e517ebf..1162e4eec 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -475,10 +475,7 @@ public final class JobRunner: JobRunnerType { return } - queues.mutate { - $0[updatedJob.variant]? - .add(updatedJob, canStartJob: canStartJob, dependencies: dependencies) - } + queues.wrappedValue[updatedJob.variant]?.add(db, job: updatedJob, canStartJob: canStartJob, dependencies: dependencies) // Don't start the queue if the job can't be started guard canStartJob else { return } @@ -501,7 +498,7 @@ public final class JobRunner: JobRunnerType { return } - queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob, dependencies: dependencies) + queues.wrappedValue[job.variant]?.upsert(db, job: job, canStartJob: canStartJob, dependencies: dependencies) // Don't start the queue if the job can't be started guard canStartJob else { return } @@ -618,14 +615,28 @@ public final class JobQueue { /// on our random queue threads results in the timer never firing, the `start` method will redirect itself to /// the correct thread let trigger: Trigger = Trigger() - trigger.fireTimestamp = max(1, (timestamp - Date().timeIntervalSince1970)) - trigger.timer = Timer.scheduledTimerOnMainThread( - withTimeInterval: trigger.fireTimestamp, - repeats: false, - block: { [weak queue] _ in - queue?.start(dependencies: dependencies) - } - ) + trigger.fireTimestamp = max(1, (timestamp - dependencies.date.timeIntervalSince1970)) + + switch HasAppContext() && CurrentAppContext().isRunningTests { + case true: + // When running unit tests don't schedule a proper Timer, use a while loop instead + DispatchQueue.global(qos: .default).async { [weak queue] in + while timestamp < dependencies.date.timeIntervalSince1970 { + Thread.sleep(forTimeInterval: 0.01) // Wait for 10ms + } + + queue?.start(dependencies: dependencies) + } + + case false: + trigger.timer = Timer.scheduledTimerOnMainThread( + withTimeInterval: trigger.fireTimestamp, + repeats: false, + block: { [weak queue] _ in + queue?.start(dependencies: dependencies) + } + ) + } return trigger } @@ -711,12 +722,12 @@ public final class JobQueue { // MARK: - Execution - fileprivate func add(_ job: Job, canStartJob: Bool = true, dependencies: Dependencies) { + fileprivate func add(_ db: Database, job: Job, canStartJob: Bool = true, dependencies: Dependencies) { // Check if the job should be added to the queue guard canStartJob, job.behaviour != .runOnceNextLaunch, - job.nextRunTimestamp <= Date().timeIntervalSince1970 + job.nextRunTimestamp <= dependencies.date.timeIntervalSince1970 else { return } guard job.id != nil else { SNLog("[JobRunner] Prevented attempt to add \(job.variant) job without id to queue") @@ -724,6 +735,15 @@ public final class JobQueue { } pendingJobsQueue.mutate { $0.append(job) } + + // If this is a concurrent queue then we should immediately start the next job + guard executionType == .concurrent else { return } + + // Ensure that the database commit has completed and then trigger the next job to run (need + // to ensure any interactions have been correctly inserted first) + db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Add: \(job.variant)") { [weak self] _ in + self?.runNextJob(dependencies: dependencies) + } } /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start @@ -731,7 +751,7 @@ public final class JobQueue { /// /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` /// is in the future then the job won't be started - fileprivate func upsert(_ job: Job, canStartJob: Bool = true, dependencies: Dependencies) { + fileprivate func upsert(_ db: Database, job: Job, canStartJob: Bool = true, dependencies: Dependencies) { guard let jobId: Int64 = job.id else { SNLog("[JobRunner] Prevented attempt to upsert \(job.variant) job without id to queue") return @@ -754,7 +774,7 @@ public final class JobQueue { // If we didn't update an existing job then we need to add it to the pendingJobsQueue guard !didUpdateExistingJob else { return } - add(job, canStartJob: canStartJob, dependencies: dependencies) + add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } fileprivate func insert(_ job: Job, before otherJob: Job, dependencies: Dependencies) { @@ -790,7 +810,11 @@ public final class JobQueue { } } - fileprivate func appDidBecomeActive(with jobs: [Job], canStart: Bool) { + fileprivate func appDidBecomeActive( + with jobs: [Job], + canStart: Bool, + dependencies: Dependencies + ) { let currentlyRunningJobIds: Set = jobsCurrentlyRunning.wrappedValue pendingJobsQueue.mutate { queue in @@ -826,7 +850,7 @@ public final class JobQueue { fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { guard let detailsData: Data = detailsData else { return false } - let pendingJobs: [Job] = queue.wrappedValue + let pendingJobs: [Job] = pendingJobsQueue.wrappedValue guard !pendingJobs.contains(where: { job in job.details == detailsData }) else { return true } @@ -1013,7 +1037,7 @@ public final class JobQueue { /// /// **Note:** We don't add the current job back the the queue because it should only be re-added if it's dependencies /// are successfully completed - let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys) + let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys.map { $0.id }) let dependencyJobsNotCurrentlyRunning: [Job] = dependencyInfo.jobs .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } @@ -1023,7 +1047,7 @@ public final class JobQueue { .filter { !dependencyJobsNotCurrentlyRunning.contains($0) } .inserting(contentsOf: dependencyJobsNotCurrentlyRunning, at: 0) } - handleJobDeferred(nextJob) + handleJobDeferred(nextJob, dependencies: dependencies) return } @@ -1105,7 +1129,7 @@ public final class JobQueue { } // If the next job isn't scheduled in the future then just restart the JobRunner immediately - let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - Date().timeIntervalSince1970) + let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - dependencies.date.timeIntervalSince1970) guard secondsUntilNextJob > 0 else { // Only log that the queue is getting restarted if this queue had actually been about to stop @@ -1218,7 +1242,7 @@ public final class JobQueue { /// **Note:** If any of these `dependantJobs` have other dependencies then when they attempt to start they will be /// removed from the queue, replaced by their dependencies if !dependantJobs.isEmpty { - let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys) + let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys.map { $0.id }) let dependantJobsNotCurrentlyRunning: [Job] = dependantJobs .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } @@ -1288,7 +1312,7 @@ public final class JobQueue { // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) - let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) + let nextRunTimestamp: TimeInterval = (dependencies.date.timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) dependencies.storage.write { db in /// Remove any dependant jobs from the queue (shouldn't be in there but filter the queue just in case so we don't try @@ -1313,7 +1337,7 @@ public final class JobQueue { updatedFailureCount <= maxFailureCount ) else { - SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")") + SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 && updatedFailureCount > maxFailureCount ? "; too many retries" : "")") // If the job permanently failed or we have performed all of our retry attempts // then delete the job and all of it's dependant jobs (it'll probably never succeed) @@ -1364,12 +1388,12 @@ public final class JobQueue { guard let lastRecord: (count: Int, times: [TimeInterval]) = $0[job.id] else { $0 = $0.setting( job.id, - (1, [Date().timeIntervalSince1970]) + (1, [dependencies.date.timeIntervalSince1970]) ) return } - let timeNow: TimeInterval = Date().timeIntervalSince1970 + let timeNow: TimeInterval = dependencies.date.timeIntervalSince1970 stuckInDeferLoop = ( lastRecord.count >= JobQueue.deferralLoopThreshold && (timeNow - lastRecord.times[0]) < CGFloat(lastRecord.count) diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift index 8c4db0211..35f22bb8a 100644 --- a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -9,106 +9,97 @@ import Nimble @testable import SessionUtilitiesKit class JobRunnerSpec: QuickSpec { - enum TestSuccessfulJob: JobExecutor { - static let maxFailureCount: Int = 0 - static let requiresThreadId: Bool = false - static let requiresInteractionId: Bool = false - - static func run( - _ job: Job, - queue: DispatchQueue, - success: @escaping (Job, Bool, Dependencies) -> (), - failure: @escaping (Job, Error?, Bool, Dependencies) -> (), - deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies - ) { - guard dependencies.date.timeIntervalSinceNow > 0 else { return success(job, true, dependencies) } - - queue.asyncAfter(deadline: .now() + .milliseconds(Int(dependencies.date.timeIntervalSinceNow * 1000))) { - success(job, true, dependencies) - } - } - } - - enum TestFailedJob: JobExecutor { - static let maxFailureCount: Int = 1 - static let requiresThreadId: Bool = false - static let requiresInteractionId: Bool = false - - static func run( - _ job: Job, - queue: DispatchQueue, - success: @escaping (Job, Bool, Dependencies) -> (), - failure: @escaping (Job, Error?, Bool, Dependencies) -> (), - deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies - ) { - guard dependencies.date.timeIntervalSinceNow > 0 else { return failure(job, nil, false, dependencies) } - - queue.asyncAfter(deadline: .now() + .milliseconds(Int(dependencies.date.timeIntervalSinceNow * 1000))) { - failure(job, nil, false, dependencies) - } - } - } - - enum TestPermanentFailureJob: JobExecutor { - static let maxFailureCount: Int = 1 - static let requiresThreadId: Bool = false - static let requiresInteractionId: Bool = false - - static func run( - _ job: Job, - queue: DispatchQueue, - success: @escaping (Job, Bool, Dependencies) -> (), - failure: @escaping (Job, Error?, Bool, Dependencies) -> (), - deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies - ) { - guard dependencies.date.timeIntervalSinceNow > 0 else { return failure(job, nil, true, dependencies) } - - queue.asyncAfter(deadline: .now() + .milliseconds(Int(dependencies.date.timeIntervalSinceNow * 1000))) { - failure(job, nil, true, dependencies) - } - } - } - - enum TestDeferredJob: JobExecutor { - static let maxFailureCount: Int = 0 - static let requiresThreadId: Bool = false - static let requiresInteractionId: Bool = false - - static func run( - _ job: Job, - queue: DispatchQueue, - success: @escaping (Job, Bool, Dependencies) -> (), - failure: @escaping (Job, Error?, Bool, Dependencies) -> (), - deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies - ) { - guard dependencies.date.timeIntervalSinceNow > 0 else { return deferred(job, dependencies) } - - queue.asyncAfter(deadline: .now() + .milliseconds(Int(dependencies.date.timeIntervalSinceNow * 1000))) { - deferred(job, dependencies) - } - } - } - struct TestDetails: Codable { + enum ResultType: Codable { + case success + case failure + case permanentFailure + case deferred + } + + public let result: ResultType + public let completeTime: Int public let intValue: Int64 public let stringValue: String + + init( + result: ResultType = .success, + completeTime: Int = 0, + intValue: Int64 = 100, + stringValue: String = "200" + ) { + self.result = result + self.completeTime = completeTime + self.intValue = intValue + self.stringValue = stringValue + } } struct InvalidDetails: Codable { func encode(to encoder: Encoder) throws { throw HTTP.Error.parsingFailed } } + enum TestJob: JobExecutor { + static let maxFailureCount: Int = 1 + static let requiresThreadId: Bool = false + static let requiresInteractionId: Bool = false + + static func run( + _ job: Job, + queue: DispatchQueue, + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies + ) { + guard + let detailsData: Data = job.details, + let details: TestDetails = try? JSONDecoder().decode(TestDetails.self, from: detailsData) + else { return success(job, true, dependencies) } + + let completeJob: () -> () = { + // Need to auto-increment the 'completeTime' and 'nextRunTimestamp' to prevent the job + // from immediately being run again + let updatedJob: Job = job + .with(nextRunTimestamp: (max(1234567890, job.nextRunTimestamp) + 0.5)) + .with( + details: TestDetails( + result: details.result, + completeTime: (details.completeTime + 1), + intValue: details.intValue, + stringValue: details.stringValue + ) + )! + dependencies.storage.write { db in try _ = updatedJob.saved(db) } + + switch details.result { + case .success: success(job, true, dependencies) + case .failure: failure(job, nil, false, dependencies) + case .permanentFailure: failure(job, nil, true, dependencies) + case .deferred: deferred(updatedJob, dependencies) + } + } + + guard dependencies.fixedTime < details.completeTime else { return completeJob() } + + DispatchQueue.global(qos: .default).async { + while dependencies.fixedTime < details.completeTime { + Thread.sleep(forTimeInterval: 0.01) // Wait for 10ms + } + + queue.async { + completeJob() + } + } + } + } + // MARK: - Spec override func spec() { var jobRunner: JobRunnerType! var job1: Job! var job2: Job! - var jobDetails: TestDetails! var mockStorage: Storage! var dependencies: Dependencies! @@ -142,10 +133,6 @@ class JobRunnerSpec: QuickSpec { interactionId: nil, details: nil ) - jobDetails = TestDetails( - intValue: 100, - stringValue: "200" - ) job2 = Job( id: 101, failureCount: 0, @@ -156,16 +143,21 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try! JSONEncoder().encode(jobDetails) + details: nil ) jobRunner = JobRunner(isTestingJobRunner: true, dependencies: dependencies) + jobRunner.setExecutor(TestJob.self, for: .messageSend) + jobRunner.setExecutor(TestJob.self, for: .attachmentUpload) + jobRunner.setExecutor(TestJob.self, for: .attachmentDownload) // Need to assign this to ensure it's used by nested dependencies dependencies.jobRunner = jobRunner } afterEach { + /// We **must** set `fixedTime` to ensure we break any loops within the `TestJob` executor + dependencies.fixedTime = Int.max jobRunner.stopAndClearPendingJobs() jobRunner = nil mockStorage = nil @@ -175,10 +167,36 @@ class JobRunnerSpec: QuickSpec { context("when configuring") { it("adds an executor correctly") { + job1 = Job( + id: 101, + failureCount: 0, + variant: .getSnodePool, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + ) jobRunner.appDidFinishLaunching(dependencies: dependencies) - // First check that it fails to start - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + mockStorage.write { db in + jobRunner.upsert( + db, + job: job1, + canStartJob: true, + dependencies: dependencies + ) + } + + expect(jobRunner.isCurrentlyRunning(job1)) + .toEventually( + beFalse(), + timeout: .milliseconds(50) + ) + + jobRunner.setExecutor(TestJob.self, for: .getSnodePool) mockStorage.write { db in jobRunner.upsert( @@ -192,27 +210,7 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) - ) - - jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) - - // Then check that it succeeded to start - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay - - mockStorage.write { db in - jobRunner.upsert( - db, - job: job1, - canStartJob: true, - dependencies: dependencies - ) - } - - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } } @@ -224,10 +222,6 @@ class JobRunnerSpec: QuickSpec { // MARK: ---- by checking if a job is currently running context("by checking if a job is currently running") { - beforeEach { - jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) - } - it("returns false when not given a job") { expect(jobRunner.isCurrentlyRunning(nil)).to(beFalse()) } @@ -243,10 +237,9 @@ class JobRunnerSpec: QuickSpec { } it("returns true when given a non blocking job that is running") { + job1 = job1.with(details: TestDetails(completeTime: 1)) jobRunner.appDidFinishLaunching(dependencies: dependencies) - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay - mockStorage.write { db in jobRunner.upsert( db, @@ -259,7 +252,7 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beTrue(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } @@ -274,7 +267,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in @@ -288,13 +281,12 @@ class JobRunnerSpec: QuickSpec { ) } - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidFinishLaunching(dependencies: dependencies) expect(jobRunner.isCurrentlyRunning(job2)) .toEventually( beTrue(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } } @@ -302,12 +294,6 @@ class JobRunnerSpec: QuickSpec { // MARK: ---- by getting the details for jobs context("by getting the details for jobs") { - beforeEach { - jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) - jobRunner.setExecutor(TestSuccessfulJob.self, for: .attachmentUpload) - jobRunner.setExecutor(TestSuccessfulJob.self, for: .attachmentDownload) - } - it("returns an empty dictionary when there are no jobs") { expect(jobRunner.details()).to(equal([:])) } @@ -315,8 +301,6 @@ class JobRunnerSpec: QuickSpec { it("returns an empty dictionary when there are no jobs matching the filters") { jobRunner.appDidFinishLaunching(dependencies: dependencies) - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay - mockStorage.write { db in jobRunner.upsert( db, @@ -329,7 +313,7 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.detailsFor(state: .running, variant: .messageSend)) .toEventually( equal([:]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } @@ -350,7 +334,7 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.details()) .toEventuallyNot( beEmpty(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.detailsFor(jobs: [job1])).to(equal([:])) expect(jobRunner.detailsFor(jobs: [job2])).to(equal([101: job2.details])) @@ -367,7 +351,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try! JSONEncoder().encode(jobDetails) + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) ) job2 = Job( id: 101, @@ -379,9 +363,8 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) ) - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidFinishLaunching(dependencies: dependencies) mockStorage.write { db in @@ -398,7 +381,7 @@ class JobRunnerSpec: QuickSpec { jobRunner.upsert( db, job: job2, - canStartJob: true, + canStartJob: false, dependencies: dependencies ) } @@ -406,8 +389,8 @@ class JobRunnerSpec: QuickSpec { // Wait for there to be data and the validate the filtering works expect(jobRunner.detailsFor(state: .running)) .toEventually( - equal([100: try! JSONEncoder().encode(jobDetails)]), - timeout: .milliseconds(10) + equal([100: try! JSONEncoder().encode(TestDetails(completeTime: 1))]), + timeout: .milliseconds(50) ) expect(Array(jobRunner.details().keys).sorted()).to(equal([100, 101])) } @@ -423,7 +406,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) ) job2 = Job( id: 101, @@ -435,9 +418,8 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try! JSONEncoder().encode(jobDetails) + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) ) - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidFinishLaunching(dependencies: dependencies) mockStorage.write { db in @@ -462,14 +444,15 @@ class JobRunnerSpec: QuickSpec { // Wait for there to be data and the validate the filtering works expect(jobRunner.detailsFor(state: .pending)) .toEventually( - equal([101: try! JSONEncoder().encode(jobDetails)]), - timeout: .milliseconds(10) + equal([101: try! JSONEncoder().encode(TestDetails(completeTime: 1))]), + timeout: .milliseconds(50) ) expect(Array(jobRunner.details().keys).sorted()).to(equal([100, 101])) } it("can filter to specific variants") { - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + job1 = job1.with(details: TestDetails(completeTime: 1)) + job2 = job2.with(details: TestDetails(completeTime: 2)) jobRunner.appDidFinishLaunching(dependencies: dependencies) mockStorage.write { db in @@ -494,17 +477,20 @@ class JobRunnerSpec: QuickSpec { // Wait for there to be data and the validate the filtering works expect(jobRunner.detailsFor(variant: .attachmentUpload)) .toEventually( - equal([101: try! JSONEncoder().encode(jobDetails)]), - timeout: .milliseconds(10) + equal([101: try! JSONEncoder().encode(TestDetails(completeTime: 2))]), + timeout: .milliseconds(50) + ) + expect(Array(jobRunner.details().keys).sorted()) + .toEventually( + equal([100, 101]), + timeout: .milliseconds(50) ) - expect(Array(jobRunner.details().keys).sorted()).to(equal([100, 101])) } it("includes non blocking jobs") { + job2 = job2.with(details: TestDetails(completeTime: 1)) jobRunner.appDidFinishLaunching(dependencies: dependencies) - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay - mockStorage.write { db in jobRunner.upsert( db, @@ -516,8 +502,8 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.detailsFor(state: .running, variant: .attachmentUpload)) .toEventually( - equal([101: try! JSONEncoder().encode(jobDetails)]), - timeout: .milliseconds(10) + equal([101: try! JSONEncoder().encode(TestDetails(completeTime: 1))]), + timeout: .milliseconds(50) ) } @@ -532,7 +518,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try! JSONEncoder().encode(jobDetails) + details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in @@ -546,13 +532,12 @@ class JobRunnerSpec: QuickSpec { ) } - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidFinishLaunching(dependencies: dependencies) expect(jobRunner.detailsFor(state: .running, variant: .attachmentUpload)) .toEventually( - equal([101: try! JSONEncoder().encode(jobDetails)]), - timeout: .milliseconds(10) + equal([101: try! JSONEncoder().encode(TestDetails(completeTime: 1))]), + timeout: .milliseconds(50) ) } } @@ -560,10 +545,6 @@ class JobRunnerSpec: QuickSpec { // MARK: ---- by checking for an existing job context("by checking for an existing job") { - beforeEach { - jobRunner.setExecutor(TestSuccessfulJob.self, for: .attachmentUpload) - } - it("returns false for a queue that doesn't exist") { jobRunner = JobRunner( isTestingJobRunner: true, @@ -571,7 +552,7 @@ class JobRunnerSpec: QuickSpec { dependencies: dependencies ) - expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails())) .to(beFalse()) } @@ -581,11 +562,12 @@ class JobRunnerSpec: QuickSpec { } it("returns false when there is not a pending or running job") { - expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails())) .to(beFalse()) } it("returns true when there is a pending job") { + job2 = job2.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in jobRunner.upsert( db, @@ -601,17 +583,16 @@ class JobRunnerSpec: QuickSpec { expect(Array(jobRunner.detailsFor(state: .pending, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) - expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } it("returns true when there is a running job") { + job2 = job2.with(details: TestDetails(completeTime: 1)) jobRunner.appDidFinishLaunching(dependencies: dependencies) - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay - mockStorage.write { db in jobRunner.upsert( db, @@ -624,9 +605,9 @@ class JobRunnerSpec: QuickSpec { expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) - expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } @@ -641,7 +622,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try! JSONEncoder().encode(jobDetails) + details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in @@ -655,20 +636,19 @@ class JobRunnerSpec: QuickSpec { ) } - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidFinishLaunching(dependencies: dependencies) expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) - expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } it("returns true when there is a non blocking job") { - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + job2 = job2.with(details: TestDetails(completeTime: 1)) jobRunner.appDidFinishLaunching(dependencies: dependencies) mockStorage.write { db in @@ -683,9 +663,9 @@ class JobRunnerSpec: QuickSpec { expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) - expect(jobRunner.hasJob(of: .attachmentUpload, with: jobDetails)) + expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } } @@ -693,12 +673,8 @@ class JobRunnerSpec: QuickSpec { // MARK: ---- by being notified of app launch context("by being notified of app launch") { - beforeEach { - jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) - } - it("does not start a job before getting the app launch call") { - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in jobRunner.upsert( @@ -712,12 +688,12 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } it("does nothing if there are no app launch jobs") { - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in jobRunner.upsert( @@ -733,11 +709,12 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } it("starts the job queues after completing blocking app launch jobs") { + job1 = job1.with(details: TestDetails(completeTime: 2)) job2 = Job( id: 101, failureCount: 0, @@ -748,7 +725,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in @@ -772,32 +749,34 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) // Make sure it starts - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidFinishLaunching(dependencies: dependencies) // Blocking job running but blocked job not expect(jobRunner.isCurrentlyRunning(job2)) .toEventually( beTrue(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + // Complete 'job2' + dependencies.fixedTime = 1 + // Blocked job eventually starts - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beTrue(), - timeout: .milliseconds(20) + timeout: .milliseconds(50) ) } it("starts the job queues alongside non blocking app launch jobs") { + job1 = job1.with(details: TestDetails(completeTime: 1)) job2 = Job( id: 101, failureCount: 0, @@ -808,7 +787,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in @@ -832,22 +811,21 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) // Make sure it starts - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidFinishLaunching(dependencies: dependencies) expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beTrue(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.isCurrentlyRunning(job2)) .toEventually( beTrue(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } } @@ -855,12 +833,8 @@ class JobRunnerSpec: QuickSpec { // MARK: ---- by being notified of app becoming active context("by being notified of app becoming active") { - beforeEach { - jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) - } - it("does not start a job before getting the app active call") { - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in jobRunner.upsert( @@ -874,11 +848,12 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } it("does not start the job queues if there are no app active jobs and blocking jobs are running") { + job1 = job1.with(details: TestDetails(completeTime: 2)) job2 = Job( id: 101, failureCount: 0, @@ -889,7 +864,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in @@ -913,26 +888,19 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) // Start the blocking job - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidFinishLaunching(dependencies: dependencies) // Make sure the other queues don't start - dependencies.date = Date().addingTimeInterval(30 / 1000) // Complete job after delay jobRunner.appDidBecomeActive(dependencies: dependencies) expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) - ) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(20) + timeout: .milliseconds(50) ) } @@ -947,7 +915,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try? JSONEncoder().encode(TestDetails(completeTime: 2)) ) job2 = Job( id: 101, @@ -959,7 +927,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in @@ -984,31 +952,24 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) // Start the blocking queue - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidFinishLaunching(dependencies: dependencies) // Make sure the other queues don't start - dependencies.date = Date().addingTimeInterval(30 / 1000) // Complete job after delay jobRunner.appDidBecomeActive(dependencies: dependencies) expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) - ) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(20) + timeout: .milliseconds(50) ) } it("starts the job queues if there are no app active jobs") { - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in jobRunner.upsert( @@ -1024,7 +985,7 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beTrue(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } @@ -1039,7 +1000,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) ) job2 = Job( id: 101, @@ -1051,7 +1012,7 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: nil + details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in @@ -1076,17 +1037,16 @@ class JobRunnerSpec: QuickSpec { expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beFalse(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) // Make sure the queues are started - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay jobRunner.appDidBecomeActive(dependencies: dependencies) expect(jobRunner.isCurrentlyRunning(job1)) .toEventually( beTrue(), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) } @@ -1097,16 +1057,36 @@ class JobRunnerSpec: QuickSpec { context("when running jobs") { beforeEach { - jobRunner.setExecutor(TestSuccessfulJob.self, for: .messageSend) - jobRunner.setExecutor(TestSuccessfulJob.self, for: .attachmentUpload) jobRunner.appDidFinishLaunching(dependencies: dependencies) } + // MARK: ---- by adding + + context("by adding") { + it("does not start until after the db transaction completes") { + job1 = job1.with(details: TestDetails(completeTime: 1)) + + mockStorage.write { db in + jobRunner.add(db, job: job1, canStartJob: true, dependencies: dependencies) + + // Wait for 10ms to give the job the chance to be added + Thread.sleep(forTimeInterval: 0.01) + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .to(beEmpty()) + } + + // Wait for 10ms for the job to actually be added + Thread.sleep(forTimeInterval: 0.01) + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .to(equal([100])) + } + } // MARK: ---- with dependencies context("with dependencies") { it("starts dependencies first") { - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + job1 = job1.with(details: TestDetails(completeTime: 1)) + job2 = job2.with(details: TestDetails(completeTime: 2)) mockStorage.write { db in try job1.insert(db) @@ -1120,12 +1100,13 @@ class JobRunnerSpec: QuickSpec { expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) } it("removes the initial job from the queue") { - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + job1 = job1.with(details: TestDetails(completeTime: 1)) + job2 = job2.with(details: TestDetails(completeTime: 2)) mockStorage.write { db in try job1.insert(db) @@ -1139,13 +1120,14 @@ class JobRunnerSpec: QuickSpec { expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) } it("starts the initial job when the dependencies succeed") { - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + job1 = job1.with(details: TestDetails(completeTime: 2)) + job2 = job2.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in try job1.insert(db) @@ -1159,23 +1141,51 @@ class JobRunnerSpec: QuickSpec { expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure the initial job starts - dependencies.date = Date().addingTimeInterval(20 / 1000) // Complete job after delay + dependencies.fixedTime = 1 expect(Array(jobRunner.detailsFor(state: .running, variant: .messageSend).keys)) .toEventually( equal([100]), - timeout: .milliseconds(20) + timeout: .milliseconds(50) + ) + } + + it("does not start the initial job if the dependencies are deferred") { + job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) + job2 = job2.with(details: TestDetails(result: .deferred, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + try job2.insert(db) + try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + .toEventually( + equal([101]), + timeout: .milliseconds(50) + ) + expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + + // Make sure there are no running jobs + dependencies.fixedTime = 1 + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) ) } it("does not start the initial job if the dependencies fail") { - jobRunner.setExecutor(TestFailedJob.self, for: .attachmentUpload) - - dependencies.date = Date().addingTimeInterval(20 / 1000) // Fail job after delay + job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) + job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) @@ -1189,22 +1199,22 @@ class JobRunnerSpec: QuickSpec { expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs + dependencies.fixedTime = 1 expect(Array(jobRunner.detailsFor(state: .running).keys)) .toEventually( beEmpty(), - timeout: .milliseconds(20) + timeout: .milliseconds(50) ) } it("does not delete the initial job if the dependencies fail") { - jobRunner.setExecutor(TestFailedJob.self, for: .attachmentUpload) - - dependencies.date = Date().addingTimeInterval(20 / 1000) // Fail job after delay + job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) + job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) @@ -1218,16 +1228,16 @@ class JobRunnerSpec: QuickSpec { expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs - dependencies.date = Date().addingTimeInterval(20 / 1000) // Delay subsequent runs + dependencies.fixedTime = 1 expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( beEmpty(), - timeout: .milliseconds(20) + timeout: .milliseconds(50) ) // Stop the queues so it doesn't run out of retry attempts @@ -1238,9 +1248,8 @@ class JobRunnerSpec: QuickSpec { } it("deletes the initial job if the dependencies permanently fail") { - jobRunner.setExecutor(TestPermanentFailureJob.self, for: .attachmentUpload) - - dependencies.date = Date().addingTimeInterval(20 / 1000) // Fail job after delay + job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) + job2 = job2.with(details: TestDetails(result: .permanentFailure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) @@ -1254,15 +1263,81 @@ class JobRunnerSpec: QuickSpec { expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), - timeout: .milliseconds(10) + timeout: .milliseconds(50) ) expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs + dependencies.fixedTime = 1 expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( beEmpty(), - timeout: .milliseconds(20) + timeout: .milliseconds(50) + ) + + // Make sure the jobs were deleted + expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) + } + } + } + + // MARK: -- when completing jobs + + context("when completing jobs") { + beforeEach { + jobRunner.appDidFinishLaunching(dependencies: dependencies) + } + + // MARK: ---- by succeeding + + context("by succeeding") { + it("removes the job from the queue") { + job1 = job1.with(details: TestDetails(result: .success, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(50) + ) + + // Make sure there are no running jobs + dependencies.fixedTime = 1 + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + } + + it("deletes the job") { + job1 = job1.with(details: TestDetails(result: .success, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(50) + ) + + // Make sure there are no running jobs + dependencies.fixedTime = 1 + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) ) // Make sure the jobs were deleted @@ -1270,6 +1345,259 @@ class JobRunnerSpec: QuickSpec { } } + // MARK: ---- by deferring + + context("by deferring") { + it("reschedules the job to run again later") { + job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(50) + ) + + // Make sure there are no running jobs + dependencies.fixedTime = 1 + dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) + expect(jobRunner.detailsFor(state: .running)) + .toEventually( + equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 2))]), + timeout: .milliseconds(50) + ) + } + + it("does not delete the job") { + job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(50) + ) + + // Make sure there are no running jobs + dependencies.fixedTime = 1 + dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) + expect(jobRunner.detailsFor(state: .running)) + .toEventually( + equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 2))]), + timeout: .milliseconds(50) + ) + + // Make sure the jobs were deleted + expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0)) + } + + it("fails the job if it is deferred too many times") { + job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure it runs + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(50) + ) + dependencies.fixedTime = 1 + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + + // Restart the JobRunner + dependencies.date = Date(timeIntervalSince1970: 1234567890 + 0.5) + jobRunner.startNonBlockingQueues(dependencies: dependencies) + + // Make sure it finishes once + expect(jobRunner.detailsFor(state: .running)) + .toEventually( + equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 2))]), + timeout: .milliseconds(50) + ) + dependencies.fixedTime = 2 + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + + // Restart the JobRunner + dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) + jobRunner.startNonBlockingQueues(dependencies: dependencies) + + // Make sure it finishes twice + expect(jobRunner.detailsFor(state: .running)) + .toEventually( + equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 3))]), + timeout: .milliseconds(50) + ) + dependencies.fixedTime = 3 + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + + // Restart the JobRunner + dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1.5) + jobRunner.startNonBlockingQueues(dependencies: dependencies) + + // Make sure it's finishes the last time + expect(jobRunner.detailsFor(state: .running)) + .toEventually( + equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 4))]), + timeout: .milliseconds(50) + ) + dependencies.fixedTime = 4 + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + + // Make sure the job was marked as failed + expect(mockStorage.read { db in try Job.fetchOne(db, id: 100)?.failureCount }).to(equal(1)) + } + } + + // MARK: ---- by failing + + context("by failing") { + it("removes the job from the queue") { + job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(50) + ) + + // Make sure there are no running jobs + dependencies.fixedTime = 1 + dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + } + + it("does not delete the job") { + job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(50) + ) + + // Make sure there are no running jobs + dependencies.fixedTime = 1 + dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + + // Make sure the jobs were deleted + expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0)) + } + } + + // MARK: ---- by permanently failing + + context("by permanently failing") { + it("removes the job from the queue") { + job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(50) + ) + + // Make sure there are no running jobs + dependencies.fixedTime = 1 + dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + } + + it("deletes the job") { + job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1)) + + mockStorage.write { db in + try job1.insert(db) + + jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + } + + // Make sure the dependency is run + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + equal([100]), + timeout: .milliseconds(50) + ) + + // Make sure there are no running jobs + dependencies.fixedTime = 1 + dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + + // Make sure the jobs were deleted + expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) + } + } } } } From 09ab977861c7d07214bbf874d014a97662ca5757 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 26 May 2023 14:27:14 +1000 Subject: [PATCH 06/50] Updated the code to decode and use updated notifications Made the JobQueue execution type explicit Fixed a bug where legacy group's might not be unsubscribed from --- Session.xcodeproj/project.pbxproj | 28 ++ .../Open Groups/Types/SodiumProtocols.swift | 1 + .../Models/NotificationMetadata.swift | 47 ++++ .../Notifications/PushNotificationAPI.swift | 77 ++++- .../Notifications/Types/ProcessResult.swift | 13 + .../Notifications/Types/Service.swift | 1 + .../MockAeadXChaCha20Poly1305Ietf.swift | 1 + .../NotificationServiceExtension.swift | 18 +- .../Networking/OnionRequestAPI.swift | 43 +-- SessionUtilitiesKit/JobRunner/JobRunner.swift | 5 +- SessionUtilitiesKit/Utilities/Bencode.swift | 263 ++++++++++++++++++ .../Utilities/BencodeSpec.swift | 97 +++++++ 12 files changed, 537 insertions(+), 57 deletions(-) create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift create mode 100644 SessionUtilitiesKit/Utilities/Bencode.swift create mode 100644 SessionUtilitiesKitTests/Utilities/BencodeSpec.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index cbe919ba5..69124a8f7 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -654,6 +654,7 @@ FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */; }; FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */; }; FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */; }; + FD6E4C8A2A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */; }; FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; FD7115EB28C5D78E00B47552 /* ThreadSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */; }; FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */; }; @@ -817,6 +818,7 @@ FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; + FDD82C3F2A205D0A00425F05 /* ProcessResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */; }; FDDC08F229A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDC08F129A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift */; }; FDDCBDA829E776BF00303C38 /* seed2-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA229E776BF00303C38 /* seed2-2023-2y.crt */; }; FDDCBDA929E776BF00303C38 /* seed1-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA329E776BF00303C38 /* seed1-2023-2y.crt */; }; @@ -910,6 +912,9 @@ FDF848F329413DB0007DCAE5 /* ImagePickerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848F229413DB0007DCAE5 /* ImagePickerHandler.swift */; }; FDF848F529413EEC007DCAE5 /* SessionCell+Styling.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848F429413EEC007DCAE5 /* SessionCell+Styling.swift */; }; FDF848F729414477007DCAE5 /* CurrentUserPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF848F629414477007DCAE5 /* CurrentUserPoller.swift */; }; + FDFBB74B2A1EFF4900CA7350 /* Bencode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFBB74A2A1EFF4900CA7350 /* Bencode.swift */; }; + FDFBB74D2A1F3C4E00CA7350 /* NotificationMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFBB74C2A1F3C4E00CA7350 /* NotificationMetadata.swift */; }; + FDFBB7542A2023EB00CA7350 /* BencodeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFBB7532A2023EB00CA7350 /* BencodeSpec.swift */; }; FDFC4D9A29F0C51500992FB6 /* String+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D22553860900C340D1 /* String+Trimming.swift */; }; FDFC4E1929F1F9A600992FB6 /* libsession-util.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDFC4E1829F1F9A600992FB6 /* libsession-util.xcframework */; }; FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D12553860800C340D1 /* Array+Utilities.swift */; }; @@ -1802,6 +1807,7 @@ FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrieveDefaultOpenGroupRoomsJob.swift; sourceTree = ""; }; FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; + FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyUnsubscribeRequest.swift; sourceTree = ""; }; FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSettingsViewModel.swift; sourceTree = ""; }; FD7115EF28C5D7DE00B47552 /* SessionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHeaderView.swift; sourceTree = ""; }; @@ -1958,6 +1964,7 @@ FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; + FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessResult.swift; sourceTree = ""; }; FDDC08F129A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionTypeConversionUtilitiesSpec.swift; sourceTree = ""; }; FDDCBDA229E776BF00303C38 /* seed2-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed2-2023-2y.crt"; sourceTree = ""; }; FDDCBDA329E776BF00303C38 /* seed1-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed1-2023-2y.crt"; sourceTree = ""; }; @@ -2054,6 +2061,9 @@ FDF848F229413DB0007DCAE5 /* ImagePickerHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePickerHandler.swift; sourceTree = ""; }; FDF848F429413EEC007DCAE5 /* SessionCell+Styling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SessionCell+Styling.swift"; sourceTree = ""; }; FDF848F629414477007DCAE5 /* CurrentUserPoller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrentUserPoller.swift; sourceTree = ""; }; + FDFBB74A2A1EFF4900CA7350 /* Bencode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bencode.swift; sourceTree = ""; }; + FDFBB74C2A1F3C4E00CA7350 /* NotificationMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationMetadata.swift; sourceTree = ""; }; + FDFBB7532A2023EB00CA7350 /* BencodeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeSpec.swift; sourceTree = ""; }; FDFC4E1829F1F9A600992FB6 /* libsession-util.xcframework */ = {isa = PBXFileReference; explicitFileType = wrapper.xcframework; includeInIndex = 0; path = "libsession-util.xcframework"; sourceTree = BUILD_DIR; }; FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGeneralCache.swift; sourceTree = ""; }; @@ -3612,6 +3622,7 @@ FD09796527F6B0A800936362 /* Utilities */ = { isa = PBXGroup; children = ( + FDFBB74A2A1EFF4900CA7350 /* Bencode.swift */, FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */, FD09796A27F6C67500936362 /* Failable.swift */, FD09797127FAA2F500936362 /* Optional+Utilities.swift */, @@ -4045,6 +4056,7 @@ FD37EA1228AB3F60003AE748 /* Database */, FD83B9B927CF20A5005E1583 /* General */, FD9B30F1293EA0AF008DEE3E /* Networking */, + FDFBB7522A2023DE00CA7350 /* Utilities */, ); path = SessionUtilitiesKitTests; sourceTree = ""; @@ -4163,6 +4175,7 @@ isa = PBXGroup; children = ( FDC13D482A16EC20007267C7 /* Service.swift */, + FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */, FDC13D4F2A16EE50007267C7 /* PushNotificationAPIEndpoint.swift */, ); path = Types; @@ -4225,9 +4238,11 @@ FDC13D4A2A16ECBA007267C7 /* SubscribeResponse.swift */, FDC13D552A171FE4007267C7 /* UnsubscribeRequest.swift */, FDC13D572A17207D007267C7 /* UnsubscribeResponse.swift */, + FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */, FDC13D532A16FF29007267C7 /* LegacyGroupRequest.swift */, FDC4382E27B383AF00C60D73 /* LegacyPushServerResponse.swift */, FDC13D592A1721C5007267C7 /* LegacyNotifyRequest.swift */, + FDFBB74C2A1F3C4E00CA7350 /* NotificationMetadata.swift */, ); path = Models; sourceTree = ""; @@ -4425,6 +4440,14 @@ path = Models; sourceTree = ""; }; + FDFBB7522A2023DE00CA7350 /* Utilities */ = { + isa = PBXGroup; + children = ( + FDFBB7532A2023EB00CA7350 /* BencodeSpec.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FDFDE122282D04E30098B17F /* Transitions */ = { isa = PBXGroup; children = ( @@ -5644,6 +5667,7 @@ C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */, C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, FD71160228C8255900B47552 /* UIControl+Combine.swift in Sources */, + FDFBB74B2A1EFF4900CA7350 /* Bencode.swift in Sources */, FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, FDF8487929405906007DCAE5 /* HTTPQueryParam.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, @@ -5758,6 +5782,7 @@ FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */, FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */, FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */, + FDD82C3F2A205D0A00425F05 /* ProcessResult.swift in Sources */, FD716E6A2850327900C96BF4 /* EndCallMode.swift in Sources */, FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, 7B81682A28B6F1420069F315 /* ReactionResponse.swift in Sources */, @@ -5771,6 +5796,7 @@ FD245C57285065F100B966DD /* Poller.swift in Sources */, FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */, FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */, + FDFBB74D2A1F3C4E00CA7350 /* NotificationMetadata.swift in Sources */, FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */, FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */, FD8ECF9029381FC200C0D1BB /* SessionUtil+UserProfile.swift in Sources */, @@ -5808,6 +5834,7 @@ FDC6D6F32860607300B04575 /* Environment.swift in Sources */, C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, + FD6E4C8A2A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift in Sources */, B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */, FD8ECF892935AB7200C0D1BB /* SessionUtilError.swift in Sources */, @@ -6155,6 +6182,7 @@ FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */, FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, + FDFBB7542A2023EB00CA7350 /* BencodeSpec.swift in Sources */, FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */, diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index 223a42e44..cbbc7e66a 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -24,6 +24,7 @@ public protocol SodiumType { public protocol AeadXChaCha20Poly1305IetfType { var KeyBytes: Int { get } var ABytes: Int { get } + var NonceBytes: Int { get } func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift new file mode 100644 index 000000000..9a3633d85 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift @@ -0,0 +1,47 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct NotificationMetadata: Codable { + private enum CodingKeys: String, CodingKey { + case accountId = "@" + case hash = "#" + case namespace = "n" + case dataLength = "l" + case dataTooLong = "B" + } + + /// Account ID (such as Session ID or closed group ID) where the message arrived. + let accountId: String + + /// The hash of the message in the swarm. + let hash: String + + /// The swarm namespace in which this message arrived. + let namespace: Int + + /// The length of the message data. This is always included, even if the message content + /// itself was too large to fit into the push notification. + let dataLength: Int + + /// This will be `true` if the data was omitted because it was too long to fit in a push + /// notification (around 2.5kB of raw data), in which case the push notification includes + /// only this metadata but not the message content itself. + let dataTooLong: Bool + } +} + +extension PushNotificationAPI.NotificationMetadata { + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + self = PushNotificationAPI.NotificationMetadata( + accountId: try container.decode(String.self, forKey: .accountId), + hash: try container.decode(String.self, forKey: .hash), + namespace: try container.decode(Int.self, forKey: .namespace), + dataLength: try container.decode(Int.self, forKey: .dataLength), + dataTooLong: ((try? container.decode(Bool.self, forKey: .dataTooLong)) ?? false) + ) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index bb5d6ce8d..66e28ae9a 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -54,13 +54,13 @@ public enum PushNotificationAPI { } let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType) - let request: SubscribeRequest = SubscribeRequest( pubkey: currentUserPublicKey, namespaces: [.default], - includeMessageData: (previewType == .nameAndPreview), // TODO: Test resubscribing when changing the type + // Note: Unfortunately we always need the message content because without the content + // control messages can't be distinguished from visible messages which results in the + // 'generic' notification being shown when receiving things like typing indicator updates + includeMessageData: true, serviceInfo: SubscribeRequest.ServiceInfo( token: hexEncodedToken ), @@ -349,14 +349,6 @@ public enum PushNotificationAPI { ) -> AnyPublisher { let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs] - // TODO: Need to validate if this is actually desired behaviour - would this check prevent the app from unsubscribing if the user switches off fast mode??? (this is what the app is currently doing) - // TODO: This flag seems like it might actually be buggy... should double check it - guard isUsingFullAPNs else { - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - return PushNotificationAPI .send( request: PushNotificationAPIRequest( @@ -385,11 +377,70 @@ public enum PushNotificationAPI { .map { _ in () } .eraseToAnyPublisher() } + + // MARK: - Notification Handling + + public static func processNotification( + notificationContent: UNNotificationContent, + dependencies: SMKDependencies = SMKDependencies() + ) -> (envelope: SNProtoEnvelope?, result: ProcessResult) { + // Make sure the notification is from the updated push server + guard notificationContent.userInfo["spns"] != nil else { + guard + let base64EncodedData: String = notificationContent.userInfo["ENCRYPTED_DATA"] as? String, + let data: Data = Data(base64Encoded: base64EncodedData), + let envelope: SNProtoEnvelope = try? MessageWrapper.unwrap(data: data) + else { return (nil, .legacyFailure) } + + // We only support legacy notifications for legacy group conversations + guard envelope.type == .closedGroupMessage else { return (envelope, .legacyForceSilent) } + + return (envelope, .legacySuccess) + } + + guard + let base64EncodedEncString: String = notificationContent.userInfo["enc_payload"] as? String, + let encData: Data = Data(base64Encoded: base64EncodedEncString), + let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(), + encData.count > dependencies.aeadXChaCha20Poly1305Ietf.NonceBytes + else { return (nil, .failure) } + + let nonce: Data = encData[0.. = try? Bencode.decodeResponse(from: decryptedData) else { + return (nil, .failure) + } + + // If the metadata says that the message was too large then we should show the generic + // notification (this is a valid case) + guard !notification.info.dataTooLong else { return (nil, .success) } + + // Check that the body we were given is valid + guard + let notificationData: Data = notification.data, + notification.info.dataLength == notificationData.count, + let envelope = try? MessageWrapper.unwrap(data: notificationData) + else { return (nil, .failure) } + + // Success, we have the notification content + return (envelope, .success) + } // MARK: - Security @discardableResult private static func getOrGenerateEncryptionKey() throws -> Data { - // TODO: May want to work this differently (will break after a phone restart if the device hasn't been unlocked yet) do { var encryptionKey: Data = try SSKDefaultKeychainStorage.shared.data( forService: keychainService, diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift new file mode 100644 index 000000000..1c72b1629 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift @@ -0,0 +1,13 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension PushNotificationAPI { + enum ProcessResult { + case success + case failure + case legacySuccess + case legacyFailure + case legacyForceSilent + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift index cc2e5157f..b9aeb904b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/Service.swift @@ -5,5 +5,6 @@ import Foundation extension PushNotificationAPI { enum Service: String, Codable { case apns + case sandbox = "apns-sandbox" // Use for push notifications in Testnet } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift index cb3888b59..a90f118ca 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift @@ -8,6 +8,7 @@ import Sodium class MockAeadXChaCha20Poly1305Ietf: Mock, AeadXChaCha20Poly1305IetfType { var KeyBytes: Int = 32 var ABytes: Int = 16 + var NonceBytes: Int = 24 func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { return accept(args: [message, secretKey, nonce, additionalData]) as? Bytes diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 3a61aa8b3..741f17f27 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -57,12 +57,22 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) } + let (maybeEnvelope, result) = PushNotificationAPI.processNotification( + notificationContent: notificationContent + ) + guard - let base64EncodedData: String = notificationContent.userInfo["ENCRYPTED_DATA"] as? String, - let data: Data = Data(base64Encoded: base64EncodedData), - let envelope = try? MessageWrapper.unwrap(data: data) + (result == .success || result == .legacySuccess), + let envelope: SNProtoEnvelope = maybeEnvelope else { - return self.handleFailure(for: notificationContent) + switch result { + // If we got an explicit failure, or we got a success but no content then show + // the fallback notification + case .success, .legacySuccess, .failure, .legacyFailure: + return self.handleFailure(for: notificationContent) + + case .legacyForceSilent: return + } } // HACK: It is important to use write synchronously here to avoid a race condition diff --git a/SessionSnodeKit/Networking/OnionRequestAPI.swift b/SessionSnodeKit/Networking/OnionRequestAPI.swift index 538733eea..100a8d380 100644 --- a/SessionSnodeKit/Networking/OnionRequestAPI.swift +++ b/SessionSnodeKit/Networking/OnionRequestAPI.swift @@ -784,50 +784,15 @@ public enum OnionRequestAPI: OnionRequestAPIType { } public static func process(bencodedData data: Data) -> (info: ResponseInfoType, body: Data?)? { - // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break - // the data into parts to properly process it - guard let responseString: String = String(data: data, encoding: .ascii), responseString.starts(with: "l") else { + guard let response: BencodeResponse = try? Bencode.decodeResponse(from: data) else { return nil } - let stringParts: [String.SubSequence] = responseString.split(separator: ":") - - guard stringParts.count > 1, let infoLength: Int = Int(stringParts[0].suffix(from: stringParts[0].index(stringParts[0].startIndex, offsetBy: 1))) else { - return nil - } - - let infoStringStartIndex: String.Index = responseString.index(responseString.startIndex, offsetBy: "l\(infoLength):".count) - let infoStringEndIndex: String.Index = responseString.index(infoStringStartIndex, offsetBy: infoLength) - let infoString: String = String(responseString[infoStringStartIndex.. "l\(infoLength)\(infoString)e".count else { - return (responseInfo, nil) - } - - // Extract the response data as well - let dataString: String = String(responseString.suffix(from: infoStringEndIndex)) - let dataStringParts: [String.SubSequence] = dataString.split(separator: ":") - - guard dataStringParts.count > 1, let finalDataLength: Int = Int(dataStringParts[0]), let suffixData: Data = "e".data(using: .utf8) else { - return nil - } - - let dataBytes: Array = Array(data) - let dataEndIndex: Int = (dataBytes.count - suffixData.count) - let dataStartIndex: Int = (dataEndIndex - finalDataLength) - let finalDataBytes: ArraySlice = dataBytes[dataStartIndex.. = Atomic( JobQueue( type: .blocking, + executionType: .serial, qos: .default, jobVariants: [], onQueueDrained: { @@ -85,6 +86,7 @@ public final class JobRunner { ) let attachmentDownloadQueue: JobQueue = JobQueue( type: .attachmentDownload, + executionType: .serial, qos: .utility, jobVariants: [ jobVariants.remove(.attachmentDownload) @@ -92,6 +94,7 @@ public final class JobRunner { ) let generalQueue: JobQueue = JobQueue( type: .general(number: 0), + executionType: .serial, qos: .utility, jobVariants: Array(jobVariants) ) @@ -509,7 +512,7 @@ private final class JobQueue { init( type: QueueType, - executionType: ExecutionType = .serial, + executionType: ExecutionType, qos: DispatchQoS, jobVariants: [Job.Variant], onQueueDrained: (() -> ())? = nil diff --git a/SessionUtilitiesKit/Utilities/Bencode.swift b/SessionUtilitiesKit/Utilities/Bencode.swift new file mode 100644 index 000000000..1138208cc --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Bencode.swift @@ -0,0 +1,263 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public protocol BencodableType { + associatedtype ValueType: BencodableType + + static var isCollection: Bool { get } + static var isDictionary: Bool { get } +} + +public struct BencodeResponse { + public let info: T + public let data: Data? +} + +extension BencodeResponse: Equatable where T: Equatable {} + +public enum Bencode { + private enum Element: Character { + case number0 = "0" + case number1 = "1" + case number2 = "2" + case number3 = "3" + case number4 = "4" + case number5 = "5" + case number6 = "6" + case number7 = "7" + case number8 = "8" + case number9 = "9" + case intIndicator = "i" + case listIndicator = "l" + case dictIndicator = "d" + case endIndicator = "e" + case separator = ":" + + init?(_ byte: UInt8?) { + guard + let byte: UInt8 = byte, + let byteString: String = String(data: Data([byte]), encoding: .utf8), + let character: Character = byteString.first, + let result: Element = Element(rawValue: character) + else { return nil } + + self = result + } + } + + private struct BencodeString { + let value: String? + let rawValue: Data + } + + // MARK: - Functions + + public static func decodeResponse( + from data: Data, + using dependencies: Dependencies = Dependencies() + ) throws -> BencodeResponse where T: Decodable { + guard + let result: [Data] = try? decode([Data].self, from: data), + let responseData: Data = result.first + else { throw HTTPError.parsingFailed } + + return BencodeResponse( + info: try responseData.decoded(as: T.self, using: dependencies), + data: (result.count > 1 ? result.last : nil) + ) + } + + public static func decode(_ type: T.Type, from data: Data) throws -> T { + guard + let decodedData: (value: Any, remainingData: Data) = decodeData(data), + decodedData.remainingData.isEmpty == true // Ensure there is no left over data + else { throw HTTPError.parsingFailed } + + return try recursiveCast(type, from: decodedData.value) + } + + // MARK: - Logic + + private static func decodeData(_ data: Data) -> (value: Any, remainingData: Data)? { + switch Element(data.first) { + case .number0, .number1, .number2, .number3, .number4, + .number5, .number6, .number7, .number8, .number9: + return decodeString(data) + + case .intIndicator: return decodeInt(data) + case .listIndicator: return decodeList(data) + case .dictIndicator: return decodeDict(data) + default: return nil + } + } + + /// Decode a string element from iterator assumed to have structure `{length}:{data}` + private static func decodeString(_ data: Data) -> (value: BencodeString, remainingData: Data)? { + var mutableData: Data = data + var lengthData: [UInt8] = [] + + // Remove bytes until we hit the separator + while let next: UInt8 = mutableData.popFirst(), Element(next) != .separator { + lengthData.append(next) + } + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + mutableData = Data(mutableData) + + guard + let lengthString: String = String(data: Data(lengthData), encoding: .ascii), + let length: Int = Int(lengthString, radix: 10), + mutableData.count >= length + else { return nil } + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + return ( + BencodeString( + value: String(data: mutableData[0.. (value: Int, remainingData: Data)? { + var mutableData: Data = data + var intData: [UInt8] = [] + _ = mutableData.popFirst() // drop `i` + + // Pop until after `e` + while let next: UInt8 = mutableData.popFirst(), Element(next) != .endIndicator { + intData.append(next) + } + + guard + let intString: String = String(data: Data(intData), encoding: .ascii), + let result: Int = Int(intString, radix: 10) + else { return nil } + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + return (result, Data(mutableData)) + } + + /// Decode a list element from iterator assumed to have structure `l{data}e` + private static func decodeList(_ data: Data) -> ([Any], Data)? { + var mutableData: Data = data + var listElements: [Any] = [] + _ = mutableData.popFirst() // drop `l` + + while !mutableData.isEmpty, let next: UInt8 = mutableData.first, Element(next) != .endIndicator { + guard let result = decodeData(mutableData) else { break } + + listElements.append(result.value) + mutableData = result.remainingData + } + + _ = mutableData.popFirst() // drop `e` + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + return (listElements, Data(mutableData)) + } + + /// Decode a dict element from iterator assumed to have structure `d{data}e` + private static func decodeDict(_ data: Data) -> ([String: Any], Data)? { + var mutableData: Data = data + var dictElements: [String: Any] = [:] + _ = mutableData.popFirst() // drop `d` + + while !mutableData.isEmpty, let next: UInt8 = mutableData.first, Element(next) != .endIndicator { + guard + let keyResult = decodeString(mutableData), + let key: String = keyResult.value.value, + let valueResult = decodeData(keyResult.remainingData) + else { return nil } + + dictElements[key] = valueResult.value + mutableData = valueResult.remainingData + } + + _ = mutableData.popFirst() // drop `e` + + // Need to reset the index of the data (it maintains the index after popping/slicing) + // See https://forums.swift.org/t/data-subscript/57195 for more info + return (dictElements, Data(mutableData)) + } + + // MARK: - Internal Functions + + private static func recursiveCast(_ type: T.Type, from value: Any) throws -> T { + switch (type.isCollection, type.isDictionary) { + case (_, true): + guard let dictValue: [String: Any] = value as? [String: Any] else { throw HTTPError.parsingFailed } + + return try ( + dictValue.mapValues { try recursiveCast(type.ValueType.self, from: $0) } as? T ?? + { throw HTTPError.parsingFailed }() + ) + + case (true, _): + guard let arrayValue: [Any] = value as? [Any] else { throw HTTPError.parsingFailed } + + return try ( + arrayValue.map { try recursiveCast(type.ValueType.self, from: $0) } as? T ?? + { throw HTTPError.parsingFailed }() + ) + + default: + switch (value, type) { + case (let bencodeString as BencodeString, is String.Type): + return try (bencodeString.value as? T ?? { throw HTTPError.parsingFailed }()) + + case (let bencodeString as BencodeString, is Optional.Type): + return try (bencodeString.value as? T ?? { throw HTTPError.parsingFailed }()) + + case (let bencodeString as BencodeString, _): + return try (bencodeString.rawValue as? T ?? { throw HTTPError.parsingFailed }()) + + default: return try (value as? T ?? { throw HTTPError.parsingFailed }()) + } + } + } +} + +// MARK: - BencodableType Extensions + +extension Data: BencodableType { + public typealias ValueType = Data + + public static var isCollection: Bool { false } + public static var isDictionary: Bool { false } +} + +extension Int: BencodableType { + public typealias ValueType = Int + + public static var isCollection: Bool { false } + public static var isDictionary: Bool { false } +} + +extension String: BencodableType { + public typealias ValueType = String + + public static var isCollection: Bool { false } + public static var isDictionary: Bool { false } +} + +extension Array: BencodableType where Element: BencodableType { + public typealias ValueType = Element + + public static var isCollection: Bool { true } + public static var isDictionary: Bool { false } +} + +extension Dictionary: BencodableType where Key == String, Value: BencodableType { + public typealias ValueType = Value + + public static var isCollection: Bool { false } + public static var isDictionary: Bool { true } +} diff --git a/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift b/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift new file mode 100644 index 000000000..08f00df10 --- /dev/null +++ b/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift @@ -0,0 +1,97 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class BencodeSpec: QuickSpec { + struct TestType: Codable, Equatable { + let intValue: Int + let stringValue: String + } + + // MARK: - Spec + + override func spec() { + describe("Bencode") { + context("when decoding") { + it("should decode a basic string") { + let basicStringData: Data = "5:howdy".data(using: .utf8)! + let result = try? Bencode.decode(String.self, from: basicStringData) + + expect(result).to(equal("howdy")) + } + + it("should decode a basic integer") { + let basicIntegerData: Data = "i3e".data(using: .utf8)! + let result = try? Bencode.decode(Int.self, from: basicIntegerData) + + expect(result).to(equal(3)) + } + + it("should decode a list of integers") { + let basicIntListData: Data = "li1ei2ee".data(using: .utf8)! + let result = try? Bencode.decode([Int].self, from: basicIntListData) + + expect(result).to(equal([1, 2])) + } + + it("should decode a basic dict") { + let basicDictData: Data = "d4:spaml1:a1:bee".data(using: .utf8)! + let result = try? Bencode.decode([String: [String]].self, from: basicDictData) + + expect(result).to(equal(["spam": ["a", "b"]])) + } + } + + context("when decoding a response") { + it("decodes successfully") { + let data: Data = "l37:{\"intValue\":100,\"stringValue\":\"Test\"}5:\u{01}\u{02}\u{03}\u{04}\u{05}e" + .data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: TestType( + intValue: 100, + stringValue: "Test" + ), + data: Data([1, 2, 3, 4, 5]) + ) + )) + } + + it("decodes successfully with no body") { + let data: Data = "l37:{\"intValue\":100,\"stringValue\":\"Test\"}e" + .data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: TestType( + intValue: 100, + stringValue: "Test" + ), + data: nil + ) + )) + } + + it("throws a parsing error when invalid") { + let data: Data = "l36:{\"INVALID\":100,\"stringValue\":\"Test\"}5:\u{01}\u{02}\u{03}\u{04}\u{05}e" + .data(using: .utf8)! + + expect { + let result: BencodeResponse = try Bencode.decodeResponse(from: data) + _ = result + }.to(throwError(HTTPError.parsingFailed)) + } + } + } + } +} From b3cad3e709587c346d7f7d037b33551f21d4b041 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 20 Jun 2023 12:31:03 +1000 Subject: [PATCH 07/50] Added in the new legacy endpoint --- Session.xcodeproj/project.pbxproj | 4 + .../Models/LegacyGroupOnlyRequest.swift | 12 +++ .../Notifications/PushNotificationAPI.swift | 77 ++++++++----------- .../Types/PushNotificationAPIEndpoint.swift | 5 +- .../General/SNUserDefaults.swift | 1 - 5 files changed, 50 insertions(+), 49 deletions(-) create mode 100644 SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupOnlyRequest.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 69124a8f7..3aeee3f0b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -813,6 +813,7 @@ FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CC27BC641200C60D73 /* Set+Utilities.swift */; }; FDC6D6F32860607300B04575 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* Environment.swift */; }; FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC6D75F2862B3F600B04575 /* Dependencies.swift */; }; + FDCD2E032A41294E00964D6A /* LegacyGroupOnlyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */; }; FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; @@ -1959,6 +1960,7 @@ FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateMessageRequest.swift; sourceTree = ""; }; FDC438CC27BC641200C60D73 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; FDC6D75F2862B3F600B04575 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; + FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGroupOnlyRequest.swift; sourceTree = ""; }; FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = ""; }; FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; @@ -4240,6 +4242,7 @@ FDC13D572A17207D007267C7 /* UnsubscribeResponse.swift */, FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */, FDC13D532A16FF29007267C7 /* LegacyGroupRequest.swift */, + FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */, FDC4382E27B383AF00C60D73 /* LegacyPushServerResponse.swift */, FDC13D592A1721C5007267C7 /* LegacyNotifyRequest.swift */, FDFBB74C2A1F3C4E00CA7350 /* NotificationMetadata.swift */, @@ -5825,6 +5828,7 @@ FD8ECF8B2935DB4B00C0D1BB /* SharedConfigMessage.swift in Sources */, FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, + FDCD2E032A41294E00964D6A /* LegacyGroupOnlyRequest.swift in Sources */, FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupOnlyRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupOnlyRequest.swift new file mode 100644 index 000000000..1a87dcf8e --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/LegacyGroupOnlyRequest.swift @@ -0,0 +1,12 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension PushNotificationAPI { + struct LegacyGroupOnlyRequest: Codable { + let token: String + let pubKey: String + let device: String + let legacyGroupPublicKeys: Set + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 66e28ae9a..befa6c45a 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -115,54 +115,39 @@ public enum PushNotificationAPI { } } ) - .flatMap { _ in - guard UserDefaults.standard[.hasUnregisteredForLegacyPushNotifications] != true else { - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return PushNotificationAPI - .send( - request: PushNotificationAPIRequest( - endpoint: .legacyUnregister, - body: LegacyUnsubscribeRequest( - token: hexEncodedToken - ) - ) - ) - .retry(maxRetryCount) - .handleEvents( - receiveCompletion: { result in - switch result { - case .finished: - /// Save that we've already unsubscribed - /// - /// **Note:** The server can return an error (`response.code != 0`) but - /// that means the server properly processed the request and the error is likely - /// due to the device not actually being previously subscribed for notifications - /// rather than actually failing to unsubscribe - UserDefaults.standard[.hasUnregisteredForLegacyPushNotifications] = true - - case .failure: SNLog("Couldn't unsubscribe for legacy notifications.") - } - } - ) - .map { _ in () } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - ].appending( + .map { _ in () } + .eraseToAnyPublisher(), // FIXME: Remove this once legacy groups are deprecated - contentsOf: legacyGroupIds - .map { legacyGroupId in - PushNotificationAPI.subscribeToLegacyGroup( - legacyGroupId: legacyGroupId, - currentUserPublicKey: currentUserPublicKey, - using: dependencies + PushNotificationAPI + .send( + request: PushNotificationAPIRequest( + endpoint: .legacyGroupsOnlySubscribe, + body: LegacyGroupOnlyRequest( + token: hexEncodedToken, + pubKey: currentUserPublicKey, + device: "ios", + legacyGroupPublicKeys: legacyGroupIds + ) ) - } - ) + ) + .decoded(as: LegacyPushServerResponse.self, using: dependencies) + .retry(maxRetryCount) + .handleEvents( + receiveOutput: { _, response in + guard response.code != 0 else { + return SNLog("Couldn't subscribe for legacy groups due to error: \(response.message ?? "nil").") + } + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't subscribe for legacy groups.") + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + ] ) .collect() .map { _ in () } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift index cc3068395..072abcc60 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/PushNotificationAPIEndpoint.swift @@ -12,6 +12,7 @@ public extension PushNotificationAPI { case legacyNotify = "notify" case legacyRegister = "register" case legacyUnregister = "unregister" + case legacyGroupsOnlySubscribe = "register_legacy_groups_only" case legacyGroupSubscribe = "subscribe_closed_group" case legacyGroupUnsubscribe = "unsubscribe_closed_group" @@ -20,7 +21,7 @@ public extension PushNotificationAPI { var server: String { switch self { case .legacyNotify, .legacyRegister, .legacyUnregister, - .legacyGroupSubscribe, .legacyGroupUnsubscribe: + .legacyGroupsOnlySubscribe, .legacyGroupSubscribe, .legacyGroupUnsubscribe: return PushNotificationAPI.legacyServer default: return PushNotificationAPI.server @@ -30,7 +31,7 @@ public extension PushNotificationAPI { var serverPublicKey: String { switch self { case .legacyNotify, .legacyRegister, .legacyUnregister, - .legacyGroupSubscribe, .legacyGroupUnsubscribe: + .legacyGroupsOnlySubscribe, .legacyGroupSubscribe, .legacyGroupUnsubscribe: return PushNotificationAPI.legacyServerPublicKey default: return PushNotificationAPI.serverPublicKey diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index 910461a3d..82f18bd64 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -31,7 +31,6 @@ public enum SNUserDefaults { case hasSeenCallIPExposureWarning case hasSeenCallMissedTips case isUsingFullAPNs - case hasUnregisteredForLegacyPushNotifications case wasUnlinked case isMainAppActive case isCallOngoing From 01d77a515c36aae189eb3118209d0c9db9e66fb7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 20 Jun 2023 12:42:54 +1000 Subject: [PATCH 08/50] Fixed a signature generation issue --- .../Notifications/Models/SubscribeRequest.swift | 8 ++++---- .../Notifications/Models/UnsubscribeRequest.swift | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift index 6c302a051..9417d232d 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/SubscribeRequest.swift @@ -54,7 +54,7 @@ extension PushNotificationAPI { private let subkey: String? /// The signature unix timestamp (seconds, not ms) - private let timestamp: TimeInterval + private let timestamp: Int64 /// When the pubkey value starts with 05 (i.e. a session ID) this is the underlying ed25519 32-byte pubkey associated with the session /// ID. When not 05, this field should not be provided. @@ -82,7 +82,7 @@ extension PushNotificationAPI { self.serviceInfo = serviceInfo self.notificationsEncryptionKey = notificationsEncryptionKey self.subkey = subkey - self.timestamp = timestamp + self.timestamp = Int64(timestamp) // Server expects rounded seconds self.ed25519PublicKey = ed25519PublicKey self.ed25519SecretKey = ed25519SecretKey } @@ -99,7 +99,7 @@ extension PushNotificationAPI { try container.encodeIfPresent(subkey, forKey: .subkey) try container.encode(namespaces.map { $0.rawValue}.sorted(), forKey: .namespaces) try container.encode(includeMessageData, forKey: .includeMessageData) - try container.encode(Int64(timestamp), forKey: .timestamp) // Server expects rounded seconds + try container.encode(timestamp, forKey: .timestamp) try container.encode(signatureBase64, forKey: .signatureBase64) try container.encode(Service.apns, forKey: .service) try container.encode(serviceInfo, forKey: .serviceInfo) @@ -126,7 +126,7 @@ extension PushNotificationAPI { /// the `namespaces` parameter. let verificationBytes: [UInt8] = "MONITOR".bytes .appending(contentsOf: pubkey.bytes) - .appending(contentsOf: "\(Int64(timestamp))".bytes) // Server expects rounded seconds + .appending(contentsOf: "\(timestamp)".bytes) .appending(contentsOf: (includeMessageData ? "1" : "0").bytes) .appending( contentsOf: namespaces diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift index e26007fee..3d76f76ab 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/UnsubscribeRequest.swift @@ -40,7 +40,7 @@ extension PushNotificationAPI { private let subkey: String? /// The signature unix timestamp (seconds, not ms) - private let timestamp: TimeInterval + private let timestamp: Int64 /// When the pubkey value starts with 05 (i.e. a session ID) this is the underlying ed25519 32-byte pubkey associated with the session /// ID. When not 05, this field should not be provided. @@ -62,7 +62,7 @@ extension PushNotificationAPI { self.pubkey = pubkey self.serviceInfo = serviceInfo self.subkey = subkey - self.timestamp = timestamp + self.timestamp = Int64(timestamp) // Server expects rounded seconds self.ed25519PublicKey = ed25519PublicKey self.ed25519SecretKey = ed25519SecretKey } From c7d090251a7f84c8f67acfa20bf54cf4fc6e8bbb Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 20 Jun 2023 15:21:30 +1000 Subject: [PATCH 09/50] Fixed an issue where subscribing to a new legacy group wouldn't have worked --- .../MessageReceiver+ClosedGroups.swift | 19 ++++-- .../MessageSender+ClosedGroups.swift | 15 ++++- .../Notifications/PushNotificationAPI.swift | 63 +++++++------------ 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index 2bfa9e813..9c3e9629e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -192,11 +192,22 @@ extension MessageReceiver { // Start polling ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey) - // Subscribe for push notifications + // Resubscribe for group push notifications + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + PushNotificationAPI - .subscribeToLegacyGroup( - legacyGroupId: groupPublicKey, - currentUserPublicKey: getUserHexEncodedPublicKey(db) + .subscribeToLegacyGroups( + currentUserPublicKey: currentUserPublicKey, + legacyGroupIds: try ClosedGroup + .select(.threadId) + .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == currentUserPublicKey) + ) + .asRequest(of: String.self) + .fetchSet(db) + .inserting(groupPublicKey) // Insert the new key just to be sure ) .sinkUntilComplete() } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index 9f4ed0dc2..9af3d016e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -118,9 +118,18 @@ extension MessageSender { .map { MessageSender.sendImmediate(preparedSendData: $0) } .appending( // Subscribe for push notifications (if enabled) - PushNotificationAPI.subscribeToLegacyGroup( - legacyGroupId: groupPublicKey, - currentUserPublicKey: userPublicKey + PushNotificationAPI.subscribeToLegacyGroups( + currentUserPublicKey: userPublicKey, + legacyGroupIds: try ClosedGroup + .select(.threadId) + .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == userPublicKey) + ) + .asRequest(of: String.self) + .fetchSet(db) + .inserting(groupPublicKey) // Insert the new key just to be sure ) ) ) diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index befa6c45a..13af32d12 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -79,7 +79,7 @@ public enum PushNotificationAPI { .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) .joining( required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + .filter(GroupMember.Columns.profileId == currentUserPublicKey) ) .asRequest(of: String.self) .fetchSet(db) @@ -118,35 +118,12 @@ public enum PushNotificationAPI { .map { _ in () } .eraseToAnyPublisher(), // FIXME: Remove this once legacy groups are deprecated - PushNotificationAPI - .send( - request: PushNotificationAPIRequest( - endpoint: .legacyGroupsOnlySubscribe, - body: LegacyGroupOnlyRequest( - token: hexEncodedToken, - pubKey: currentUserPublicKey, - device: "ios", - legacyGroupPublicKeys: legacyGroupIds - ) - ) - ) - .decoded(as: LegacyPushServerResponse.self, using: dependencies) - .retry(maxRetryCount) - .handleEvents( - receiveOutput: { _, response in - guard response.code != 0 else { - return SNLog("Couldn't subscribe for legacy groups due to error: \(response.message ?? "nil").") - } - }, - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: SNLog("Couldn't subscribe for legacy groups.") - } - } - ) - .map { _ in () } - .eraseToAnyPublisher() + PushNotificationAPI.subscribeToLegacyGroups( + forced: true, + token: hexEncodedToken, + currentUserPublicKey: currentUserPublicKey, + legacyGroupIds: legacyGroupIds + ) ] ) .collect() @@ -284,14 +261,20 @@ public enum PushNotificationAPI { // MARK: - Legacy Groups // FIXME: Remove this once legacy groups are deprecated - public static func subscribeToLegacyGroup( - legacyGroupId: String, + public static func subscribeToLegacyGroups( + forced: Bool = false, + token: String? = nil, currentUserPublicKey: String, + legacyGroupIds: Set, using dependencies: SSKDependencies = SSKDependencies() ) -> AnyPublisher { let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs] - guard isUsingFullAPNs else { + // Only continue if PNs are enabled and we have a device token + guard + (forced || isUsingFullAPNs), + let deviceToken: String = (token ?? UserDefaults.standard[.deviceToken]) + else { return Just(()) .setFailureType(to: Error.self) .eraseToAnyPublisher() @@ -300,10 +283,12 @@ public enum PushNotificationAPI { return PushNotificationAPI .send( request: PushNotificationAPIRequest( - endpoint: .legacyGroupSubscribe, - body: LegacyGroupRequest( + endpoint: .legacyGroupsOnlySubscribe, + body: LegacyGroupOnlyRequest( + token: deviceToken, pubKey: currentUserPublicKey, - closedGroupPublicKey: legacyGroupId + device: "ios", + legacyGroupPublicKeys: legacyGroupIds ) ) ) @@ -312,13 +297,13 @@ public enum PushNotificationAPI { .handleEvents( receiveOutput: { _, response in guard response.code != 0 else { - return SNLog("Couldn't subscribe for legacy group: \(legacyGroupId) due to error: \(response.message ?? "nil").") + return SNLog("Couldn't subscribe for legacy groups due to error: \(response.message ?? "nil").") } }, receiveCompletion: { result in switch result { case .finished: break - case .failure: SNLog("Couldn't subscribe for legacy group: \(legacyGroupId).") + case .failure: SNLog("Couldn't subscribe for legacy groups.") } } ) @@ -332,8 +317,6 @@ public enum PushNotificationAPI { currentUserPublicKey: String, using dependencies: SSKDependencies = SSKDependencies() ) -> AnyPublisher { - let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs] - return PushNotificationAPI .send( request: PushNotificationAPIRequest( From 0a638bf37b615d0f2b685e5d87d324413ba896bf Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 20 Jun 2023 16:33:06 +1000 Subject: [PATCH 10/50] Fixed the broken JobRunner tests Cleaned up the 'isRunningTests' logic so it isn't dependant on having an AppContext --- Session.xcodeproj/project.pbxproj | 8 +-- Session/Meta/AppDelegate.swift | 4 +- Session/Meta/AppEnvironment.swift | 3 +- Session/Meta/MainAppContext.m | 7 -- Session/Meta/Session-Prefix.pch | 1 - .../Jobs/Types/GroupLeavingJob.swift | 21 +++--- SessionMessagingKit/Utilities/AppReadiness.m | 5 +- .../NotificationServiceExtensionContext.swift | 1 - .../ShareAppExtensionContext.swift | 1 - SessionShareExtension/ShareVC.swift | 3 +- .../ThreadSettingsViewModelSpec.swift | 2 +- SessionUtilitiesKit/Configuration.swift | 5 ++ SessionUtilitiesKit/Database/Storage.swift | 9 +-- SessionUtilitiesKit/General/AppContext.h | 2 - SessionUtilitiesKit/JobRunner/JobRunner.swift | 50 +++++++------- .../JobRunner/JobRunnerSpec.swift | 67 ++++++++---------- SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 2 +- SignalUtilitiesKit/Utilities/SSKAsserts.h | 68 ------------------- .../Utilities/SwiftSingletons.swift | 8 +-- 19 files changed, 90 insertions(+), 177 deletions(-) delete mode 100755 SignalUtilitiesKit/Utilities/SSKAsserts.h diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 2947ac3a0..a93c0e9a4 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -342,7 +342,6 @@ C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB8F255A581200E217F9 /* ParamParser.swift */; }; C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBA1255A581400E217F9 /* OWSOperation.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD6E255A582000E217F9 /* NSURLSessionDataTask+StatusCode.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */; }; - C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBC2255A581700E217F9 /* SSKAsserts.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */; }; C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */; }; C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -831,13 +830,13 @@ FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; - FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; FDDCBDA829E776BF00303C38 /* seed2-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA229E776BF00303C38 /* seed2-2023-2y.crt */; }; FDDCBDA929E776BF00303C38 /* seed1-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA329E776BF00303C38 /* seed1-2023-2y.crt */; }; FDDCBDAA29E776BF00303C38 /* seed1-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA429E776BF00303C38 /* seed1-2023-2y.der */; }; FDDCBDAB29E776BF00303C38 /* seed2-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA529E776BF00303C38 /* seed2-2023-2y.der */; }; FDDCBDAC29E776BF00303C38 /* seed3-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA629E776BF00303C38 /* seed3-2023-2y.crt */; }; FDDCBDAD29E776BF00303C38 /* seed3-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */; }; + FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; @@ -1446,7 +1445,6 @@ C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURLSessionDataTask+StatusCode.m"; sourceTree = ""; }; C33FDBB6255A581600E217F9 /* DataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DataSource.m; sourceTree = ""; }; C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKKeychainStorage.swift; sourceTree = ""; }; - C33FDBC2255A581700E217F9 /* SSKAsserts.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSKAsserts.h; sourceTree = ""; }; C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOS.pb.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; @@ -1919,13 +1917,13 @@ FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = ""; }; FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; - FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; FDDCBDA229E776BF00303C38 /* seed2-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed2-2023-2y.crt"; sourceTree = ""; }; FDDCBDA329E776BF00303C38 /* seed1-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed1-2023-2y.crt"; sourceTree = ""; }; FDDCBDA429E776BF00303C38 /* seed1-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed1-2023-2y.der"; sourceTree = ""; }; FDDCBDA529E776BF00303C38 /* seed2-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed2-2023-2y.der"; sourceTree = ""; }; FDDCBDA629E776BF00303C38 /* seed3-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed3-2023-2y.crt"; sourceTree = ""; }; FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed3-2023-2y.der"; sourceTree = ""; }; + FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = ""; }; FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LintLocalizableStrings.swift; sourceTree = ""; }; FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = ""; }; @@ -3353,7 +3351,6 @@ C33FDB8F255A581200E217F9 /* ParamParser.swift */, C33FDADE255A580400E217F9 /* SwiftSingletons.swift */, C33FDB49255A580C00E217F9 /* WeakTimer.swift */, - C33FDBC2255A581700E217F9 /* SSKAsserts.h */, C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */, C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */, C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */, @@ -4238,7 +4235,6 @@ C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */, C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */, C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */, - C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */, C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */, C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */, C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */, diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 4982d7679..84df254e4 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -84,7 +84,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } // No point continuing if we are running tests - guard !CurrentAppContext().isRunningTests else { return true } + guard !SNUtilitiesKit.isRunningTests else { return true } self.window = mainWindow CurrentAppContext().mainWindow = mainWindow @@ -156,7 +156,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } func applicationDidBecomeActive(_ application: UIApplication) { - guard !CurrentAppContext().isRunningTests else { return } + guard !SNUtilitiesKit.isRunningTests else { return } UserDefaults.sharedLokiProject?[.isMainAppActive] = true diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index 1dbb82087..6c779d34f 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit import SignalUtilitiesKit public class AppEnvironment { @@ -10,7 +11,7 @@ public class AppEnvironment { public class var shared: AppEnvironment { get { return _shared } set { - guard CurrentAppContext().isRunningTests else { + guard SNUtilitiesKit.isRunningTests else { owsFailDebug("Can only switch environments in tests.") return } diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m index d7116c5e5..b7dfea6f6 100644 --- a/Session/Meta/MainAppContext.m +++ b/Session/Meta/MainAppContext.m @@ -61,8 +61,6 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic name:UIApplicationWillTerminateNotification object:nil]; - // We can't use OWSSingletonAssert() since it uses the app context. - self.appActiveBlocks = [NSMutableArray new]; return self; @@ -243,11 +241,6 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic }]; } -- (BOOL)isRunningTests -{ - return (NSProcessInfo.processInfo.environment[@"XCTestConfigurationFilePath"] != nil); -} - - (void)setNetworkActivityIndicatorVisible:(BOOL)value { [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:value]; diff --git a/Session/Meta/Session-Prefix.pch b/Session/Meta/Session-Prefix.pch index 8998c4792..ce4735256 100644 --- a/Session/Meta/Session-Prefix.pch +++ b/Session/Meta/Session-Prefix.pch @@ -10,6 +10,5 @@ #import #import - #import #import #endif diff --git a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift index 5ebe5a9cd..386990d93 100644 --- a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift @@ -13,29 +13,30 @@ public enum GroupLeavingJob: JobExecutor { public static var requiresInteractionId: Bool = true public static func run( - _ job: SessionUtilitiesKit.Job, + _ job: Job, queue: DispatchQueue, - success: @escaping (SessionUtilitiesKit.Job, Bool) -> (), - failure: @escaping (SessionUtilitiesKit.Job, Error?, Bool) -> (), - deferred: @escaping (SessionUtilitiesKit.Job) -> ()) - { + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() + ) { guard let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), let interactionId: Int64 = job.interactionId else { - failure(job, JobRunnerError.missingRequiredDetails, true) + failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) return } guard let thread: SessionThread = Storage.shared.read({ db in try? SessionThread.fetchOne(db, id: details.groupPublicKey)}) else { SNLog("Can't leave nonexistent closed group.") - failure(job, MessageSenderError.noThread, true) + failure(job, MessageSenderError.noThread, true, dependencies) return } guard let closedGroup: ClosedGroup = Storage.shared.read({ db in try? thread.closedGroup.fetchOne(db)}) else { - failure(job, MessageSenderError.invalidClosedGroupUpdate, true) + failure(job, MessageSenderError.invalidClosedGroupUpdate, true, dependencies) return } @@ -101,7 +102,7 @@ public enum GroupLeavingJob: JobExecutor { .deleteAll(db) } } - success(job, false) + success(job, false, dependencies) } .catch(on: queue) { error in Storage.shared.writeAsync { db in @@ -115,7 +116,7 @@ public enum GroupLeavingJob: JobExecutor { ] ) } - success(job, false) + success(job, false, dependencies) } .retainUntilComplete() diff --git a/SessionMessagingKit/Utilities/AppReadiness.m b/SessionMessagingKit/Utilities/AppReadiness.m index 46ee6c2b9..300f13f43 100755 --- a/SessionMessagingKit/Utilities/AppReadiness.m +++ b/SessionMessagingKit/Utilities/AppReadiness.m @@ -6,6 +6,7 @@ #import "AppContext.h" #import #import +#import NS_ASSUME_NONNULL_BEGIN @@ -60,7 +61,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)runNowOrWhenAppWillBecomeReady:(AppReadyBlock)block { - if (CurrentAppContext().isRunningTests) { + if ([SNUtilitiesKitConfiguration isRunningTests]) { // We don't need to do any "on app ready" work in the tests. return; } @@ -82,7 +83,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)runNowOrWhenAppDidBecomeReady:(AppReadyBlock)block { - if (CurrentAppContext().isRunningTests) { + if ([SNUtilitiesKitConfiguration isRunningTests]) { // We don't need to do any "on app ready" work in the tests. return; } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift index c7e89d012..7c150570b 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift @@ -61,7 +61,6 @@ final class NotificationServiceExtensionContext : NSObject, AppContext { let frame = CGRect.zero let interfaceOrientation = UIInterfaceOrientation.unknown let isRTL = false - let isRunningTests = false let reportedApplicationState = UIApplication.State.background let statusBarHeight = CGFloat.zero diff --git a/SessionShareExtension/ShareAppExtensionContext.swift b/SessionShareExtension/ShareAppExtensionContext.swift index 5f1445a53..b06b701d1 100644 --- a/SessionShareExtension/ShareAppExtensionContext.swift +++ b/SessionShareExtension/ShareAppExtensionContext.swift @@ -30,7 +30,6 @@ final class ShareAppExtensionContext: NSObject, AppContext { }() var isRTL: Bool { return ShareAppExtensionContext._isRTL } - var isRunningTests: Bool { return false } // We don't need to distinguish this in the SAE var statusBarHeight: CGFloat { return 20 } var openSystemSettingsAction: UIAlertAction? diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index 135a83e82..81baf46bc 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -5,6 +5,7 @@ import CoreServices import PromiseKit import SignalUtilitiesKit import SessionUIKit +import SessionUtilitiesKit final class ShareVC: UINavigationController, ShareViewDelegate { private var areVersionMigrationsComplete = false @@ -46,7 +47,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate { // We don't need to use applySignalAppearence in the SAE. - if CurrentAppContext().isRunningTests { + if SNUtilitiesKit.isRunningTests { // TODO: Do we need to implement isRunningTests in the SAE context? return } diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 0ecc1d0a6..57babeb54 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -228,7 +228,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { ParentType.NavItem( id: .cancel, systemItem: .cancel, - accessibilityIdentifier: "Cancel" + accessibilityIdentifier: "Cancel button" ) ])) expect(viewModel.rightNavItems.firstValue()) diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index c6f72e019..777929dd8 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -3,6 +3,10 @@ import Foundation public enum SNUtilitiesKit { // Just to make the external API nice + public static var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } + public static func migrations() -> TargetMigrations { return TargetMigrations( identifier: .utilitiesKit, @@ -29,4 +33,5 @@ public enum SNUtilitiesKit { // Just to make the external API nice @objc public final class SNUtilitiesKitConfiguration: NSObject { @objc public static var maxFileSize: UInt = 0 + @objc public static var isRunningTests: Bool { return SNUtilitiesKit.isRunningTests } } diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index c3db2cd39..21e85f5e1 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -212,11 +212,8 @@ open class Storage { for migration: Migration.Type, in target: TargetMigrations.Identifier ) { - // In test builds ignore any migration progress updates (we run in a custom database writer anyway), - // this code should be the same as 'CurrentAppContext().isRunningTests' but since the tests can run - // without being attached to a host application the `CurrentAppContext` might not have been set and - // would crash as it gets force-unwrapped - better to just do the check explicitly instead - guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + // In test builds ignore any migration progress updates (we run in a custom database writer anyway) + guard !SNUtilitiesKit.isRunningTests else { return } Storage.shared.migrationProgressUpdater?.wrappedValue(target.key(with: migration), progress) } @@ -242,7 +239,7 @@ open class Storage { // For these cases it means either the keySpec or the keychain has become corrupt so in order to // get back to a "known good state" and behave like a new install we need to reset the storage // and regenerate the key - if !CurrentAppContext().isRunningTests { + if !SNUtilitiesKit.isRunningTests { // Try to reset app by deleting database. resetAllStorage() } diff --git a/SessionUtilitiesKit/General/AppContext.h b/SessionUtilitiesKit/General/AppContext.h index 626c93a2e..82c90d809 100755 --- a/SessionUtilitiesKit/General/AppContext.h +++ b/SessionUtilitiesKit/General/AppContext.h @@ -42,8 +42,6 @@ NSString *NSStringForUIApplicationState(UIApplicationState value); // Whether the user is using a right-to-left language like Arabic. @property (nonatomic, readonly) BOOL isRTL; -@property (nonatomic, readonly) BOOL isRunningTests; - @property (atomic, nullable) UIWindow *mainWindow; // Unlike UIApplication.applicationState, this is thread-safe. diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 1162e4eec..b1812398b 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -138,7 +138,7 @@ public final class JobRunner: JobRunnerType { isTestingJobRunner || ( HasAppContext() && CurrentAppContext().isMainApp && - !CurrentAppContext().isRunningTests + !SNUtilitiesKit.isRunningTests ) ) self.blockingQueue = Atomic( @@ -609,6 +609,19 @@ public final class JobQueue { timestamp: TimeInterval, dependencies: Dependencies ) -> Trigger? { + guard !SNUtilitiesKit.isRunningTests else { + /// When running unit tests don't schedule a proper Timer, use a while loop instead and base it on the `fixedTime` + /// value instead of `dependencies.date` to simplify things + DispatchQueue.global(qos: .default).async { [weak queue] in + while dependencies.fixedTime < Int(timestamp) { + Thread.sleep(forTimeInterval: 0.01) // Wait for 10ms + } + + queue?.start(dependencies: dependencies) + } + return nil + } + /// Setup the trigger (wait at least 1 second before triggering) /// /// **Note:** We use the `Timer.scheduledTimerOnMainThread` method because running a timer @@ -616,28 +629,13 @@ public final class JobQueue { /// the correct thread let trigger: Trigger = Trigger() trigger.fireTimestamp = max(1, (timestamp - dependencies.date.timeIntervalSince1970)) - - switch HasAppContext() && CurrentAppContext().isRunningTests { - case true: - // When running unit tests don't schedule a proper Timer, use a while loop instead - DispatchQueue.global(qos: .default).async { [weak queue] in - while timestamp < dependencies.date.timeIntervalSince1970 { - Thread.sleep(forTimeInterval: 0.01) // Wait for 10ms - } - - queue?.start(dependencies: dependencies) - } - - case false: - trigger.timer = Timer.scheduledTimerOnMainThread( - withTimeInterval: trigger.fireTimestamp, - repeats: false, - block: { [weak queue] _ in - queue?.start(dependencies: dependencies) - } - ) - } - + trigger.timer = Timer.scheduledTimerOnMainThread( + withTimeInterval: trigger.fireTimestamp, + repeats: false, + block: { [weak queue] _ in + queue?.start(dependencies: dependencies) + } + ) return trigger } @@ -1129,7 +1127,11 @@ public final class JobQueue { } // If the next job isn't scheduled in the future then just restart the JobRunner immediately - let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - dependencies.date.timeIntervalSince1970) + let secondsUntilNextJob: TimeInterval = { + guard !SNUtilitiesKit.isRunningTests else { return (nextJobTimestamp - TimeInterval(dependencies.fixedTime)) } + + return (nextJobTimestamp - dependencies.date.timeIntervalSince1970) + }() guard secondsUntilNextJob > 0 else { // Only log that the queue is getting restarted if this queue had actually been about to stop diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift index 35f22bb8a..b8c3591e4 100644 --- a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -58,14 +58,14 @@ class JobRunnerSpec: QuickSpec { else { return success(job, true, dependencies) } let completeJob: () -> () = { - // Need to auto-increment the 'completeTime' and 'nextRunTimestamp' to prevent the job - // from immediately being run again + // Need to increase the 'completeTime' and 'nextRunTimestamp' to prevent the job + // from immediately being run again or immediately completing afterwards let updatedJob: Job = job - .with(nextRunTimestamp: (max(1234567890, job.nextRunTimestamp) + 0.5)) + .with(nextRunTimestamp: TimeInterval(details.completeTime + 1)) .with( details: TestDetails( result: details.result, - completeTime: (details.completeTime + 1), + completeTime: (details.completeTime + 2), intValue: details.intValue, stringValue: details.stringValue ) @@ -1366,12 +1366,13 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) expect(jobRunner.detailsFor(state: .running)) .toEventually( - equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 2))]), + beEmpty(), timeout: .milliseconds(50) ) + expect(mockStorage.read { db in try Job.select(.details).asRequest(of: Data.self).fetchOne(db) }) + .to(equal(try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 3)))) } it("does not delete the job") { @@ -1392,10 +1393,9 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) expect(jobRunner.detailsFor(state: .running)) .toEventually( - equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 2))]), + beEmpty(), timeout: .milliseconds(50) ) @@ -1425,28 +1425,10 @@ class JobRunnerSpec: QuickSpec { timeout: .milliseconds(50) ) - // Restart the JobRunner - dependencies.date = Date(timeIntervalSince1970: 1234567890 + 0.5) - jobRunner.startNonBlockingQueues(dependencies: dependencies) + // Progress the time + dependencies.fixedTime = 2 // Make sure it finishes once - expect(jobRunner.detailsFor(state: .running)) - .toEventually( - equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 2))]), - timeout: .milliseconds(50) - ) - dependencies.fixedTime = 2 - expect(Array(jobRunner.detailsFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) - - // Restart the JobRunner - dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) - jobRunner.startNonBlockingQueues(dependencies: dependencies) - - // Make sure it finishes twice expect(jobRunner.detailsFor(state: .running)) .toEventually( equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 3))]), @@ -1459,17 +1441,32 @@ class JobRunnerSpec: QuickSpec { timeout: .milliseconds(50) ) - // Restart the JobRunner - dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1.5) - jobRunner.startNonBlockingQueues(dependencies: dependencies) + // Progress the time + dependencies.fixedTime = 4 + + // Make sure it finishes twice + expect(jobRunner.detailsFor(state: .running)) + .toEventually( + equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 5))]), + timeout: .milliseconds(50) + ) + dependencies.fixedTime = 5 + expect(Array(jobRunner.detailsFor(state: .running).keys)) + .toEventually( + beEmpty(), + timeout: .milliseconds(50) + ) + + // Progress the time + dependencies.fixedTime = 6 // Make sure it's finishes the last time expect(jobRunner.detailsFor(state: .running)) .toEventually( - equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 4))]), + equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 7))]), timeout: .milliseconds(50) ) - dependencies.fixedTime = 4 + dependencies.fixedTime = 7 expect(Array(jobRunner.detailsFor(state: .running).keys)) .toEventually( beEmpty(), @@ -1502,7 +1499,6 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) expect(Array(jobRunner.detailsFor(state: .running).keys)) .toEventually( beEmpty(), @@ -1528,7 +1524,6 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) expect(Array(jobRunner.detailsFor(state: .running).keys)) .toEventually( beEmpty(), @@ -1561,7 +1556,6 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) expect(Array(jobRunner.detailsFor(state: .running).keys)) .toEventually( beEmpty(), @@ -1587,7 +1581,6 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - dependencies.date = Date(timeIntervalSince1970: 1234567890 + 1) expect(Array(jobRunner.detailsFor(state: .running).keys)) .toEventually( beEmpty(), diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index bbaac0073..86f3cf646 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -17,6 +17,6 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #import #import #import -#import #import #import +#import diff --git a/SignalUtilitiesKit/Utilities/SSKAsserts.h b/SignalUtilitiesKit/Utilities/SSKAsserts.h deleted file mode 100755 index 8fcbd48eb..000000000 --- a/SignalUtilitiesKit/Utilities/SSKAsserts.h +++ /dev/null @@ -1,68 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -#pragma mark - Singleton Asserts - -// The "singleton asserts" can be used to ensure -// that we only create a singleton once. -// -// The simplest way to use them is the OWSSingletonAssert() macro. -// It is intended to be used inside the singleton's initializer. -// -// If, however, a singleton has multiple possible initializers, -// you need to: -// -// 1. Use OWSSingletonAssertFlag() outside the class definition. -// 2. Use OWSSingletonAssertInit() in each initializer. - -#ifdef DEBUG - -#define ENFORCE_SINGLETONS - -#endif - -#ifdef ENFORCE_SINGLETONS - -#define OWSSingletonAssertFlag() static BOOL _isSingletonCreated = NO; - -#define OWSSingletonAssertInit() \ - @synchronized([self class]) { \ - if (!CurrentAppContext().isRunningTests) { \ - OWSAssertDebug(!_isSingletonCreated); \ - _isSingletonCreated = YES; \ - } \ - } - -#define OWSSingletonAssert() OWSSingletonAssertFlag() OWSSingletonAssertInit() - -#else - -#define OWSSingletonAssertFlag() -#define OWSSingletonAssertInit() -#define OWSSingletonAssert() - -#endif - -#define OWSFailDebugUnlessRunningTests(_messageFormat, ...) \ - do { \ - if (!CurrentAppContext().isRunningTests) { \ - OWSFailDebug(_messageFormat, ##__VA_ARGS__); \ - } \ - } while (0) - -#define OWSCFailDebugUnlessRunningTests(_messageFormat, ...) \ - do { \ - if (!CurrentAppContext().isRunningTests) { \ - OWSCFailDebug(_messageFormat, ##__VA_ARGS__); \ - } \ - } while (NO) - - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/SwiftSingletons.swift b/SignalUtilitiesKit/Utilities/SwiftSingletons.swift index 346069bf1..b9a5617a7 100644 --- a/SignalUtilitiesKit/Utilities/SwiftSingletons.swift +++ b/SignalUtilitiesKit/Utilities/SwiftSingletons.swift @@ -14,12 +14,8 @@ public class SwiftSingletons: NSObject { } public func register(_ singleton: AnyObject) { - guard !CurrentAppContext().isRunningTests else { - return - } - guard _isDebugAssertConfiguration() else { - return - } + guard !SNUtilitiesKit.isRunningTests else { return } + guard _isDebugAssertConfiguration() else { return } let singletonClassName = String(describing: type(of: singleton)) guard !classSet.contains(singletonClassName) else { owsFailDebug("Duplicate singleton: \(singletonClassName).") From fbdb1ad6906745155652edff943a49ec00d85893 Mon Sep 17 00:00:00 2001 From: Arshak Aghakaryan Date: Mon, 10 Jul 2023 07:17:24 +0400 Subject: [PATCH 11/50] feat: Enable upside down orientation for iPad The "upside down" orientation for iPad devices was disabled in 05814add86d765b83c91baa3120ba65569803906b55b159fbd091a95876e40a70d. However, as an iPad user, not having all orientations is quite uncomfortable and sometimes annoying. Most apps support all orientations on iPad nowadays. This commit brings back the upside down orientation in all places where it was disabled. --- .../Media Viewing & Editing/DocumentTitleViewController.swift | 4 ++++ .../MediaGalleryNavigationController.swift | 4 ++++ Session/Media Viewing & Editing/MediaTileViewController.swift | 4 ++++ Session/Meta/AppDelegate.swift | 2 +- Session/Meta/Session-Info.plist | 1 + 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Session/Media Viewing & Editing/DocumentTitleViewController.swift b/Session/Media Viewing & Editing/DocumentTitleViewController.swift index bff7c7597..ade2b9338 100644 --- a/Session/Media Viewing & Editing/DocumentTitleViewController.swift +++ b/Session/Media Viewing & Editing/DocumentTitleViewController.swift @@ -45,6 +45,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, // MARK: - UI override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.isIPad { + return .all + } + return .allButUpsideDown } diff --git a/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift b/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift index cd9062186..55283edec 100644 --- a/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift +++ b/Session/Media Viewing & Editing/MediaGalleryNavigationController.swift @@ -44,6 +44,10 @@ class MediaGalleryNavigationController: UINavigationController { // MARK: - Orientation public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.isIPad { + return .all + } + return .allButUpsideDown } diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index db7915b95..4b9bf6c4b 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -53,6 +53,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour // MARK: - UI override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.isIPad { + return .all + } + return .allButUpsideDown } diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 13046ce60..b5749419b 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -192,7 +192,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { if UIDevice.current.isIPad { - return .allButUpsideDown + return .all } return .portrait diff --git a/Session/Meta/Session-Info.plist b/Session/Meta/Session-Info.plist index be0439a2f..835938843 100644 --- a/Session/Meta/Session-Info.plist +++ b/Session/Meta/Session-Info.plist @@ -147,6 +147,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown UIViewControllerBasedStatusBarAppearance From 57dbad7e2e235ccc9757f43c8049e6948ee47b88 Mon Sep 17 00:00:00 2001 From: Arshak Aghakaryan Date: Mon, 10 Jul 2023 07:42:28 +0400 Subject: [PATCH 12/50] fix: Initial glitch when picking a photo in upside-down orientation This was caused by OWSViewController.IsLandscapeOrientationEnabled being hard-coded to return `NO`. Now, whether or not landscape orientation is enabled will be based on whether the current device is an iPad. --- .../Shared View Controllers/OWSViewController.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m b/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m index 6d2ea863b..611174095 100644 --- a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m +++ b/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m @@ -11,12 +11,12 @@ NS_ASSUME_NONNULL_BEGIN BOOL IsLandscapeOrientationEnabled(void) { - return NO; + return UIDevice.currentDevice.isIPad; } UIInterfaceOrientationMask DefaultUIInterfaceOrientationMask(void) { - return (IsLandscapeOrientationEnabled() ? UIInterfaceOrientationMaskAllButUpsideDown + return (IsLandscapeOrientationEnabled() ? UIInterfaceOrientationMaskAll : UIInterfaceOrientationMaskPortrait); } From 5464d9c97aeef198b40525fcd5ca1cfa9e48fc9f Mon Sep 17 00:00:00 2001 From: ryanzhao Date: Wed, 26 Jul 2023 17:02:48 +1000 Subject: [PATCH 13/50] fix import --- SignalUtilitiesKit/Shared View Controllers/OWSViewController.m | 1 + 1 file changed, 1 insertion(+) diff --git a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m b/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m index bca4d0a94..82b111a14 100644 --- a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m +++ b/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m @@ -8,6 +8,7 @@ #import #import #import +#import NS_ASSUME_NONNULL_BEGIN From a41f1c1366c562ef524df7e536369546a8dc573f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 14:27:41 +1000 Subject: [PATCH 14/50] Fixed the broken tests Cleaned up the Dependencies so that tests can run synchronously without having to custom set queues as much Sorted out the crypto and network dependencies to avoid needing weird dependency inheritance Fixed the flaky tests so they are no longer flaky Fixed some unexpected JobRunner behaviours Updated the CI config to use a local build directory for derivedData (now works with build tweaks) --- .drone.jsonnet | 6 +- Scripts/drone-static-upload.sh | 4 +- Session.xcodeproj/project.pbxproj | 175 +- .../Context Menu/ContextMenuVC+Action.swift | 88 +- .../ConversationVC+Interaction.swift | 129 +- .../Message Cells/MessageCell.swift | 10 +- .../Message Cells/VisibleMessageCell.swift | 14 +- ...isappearingMessagesSettingsViewModel.swift | 17 +- .../Settings/ThreadSettingsViewModel.swift | 11 +- .../Views & Modals/ReactionListSheet.swift | 18 +- .../MediaPageViewController.swift | 8 +- .../SendMediaNavigationController.swift | 2 +- Session/Notifications/AppNotifications.swift | 8 +- Session/Notifications/SyncPushTokensJob.swift | 2 +- Session/Onboarding/Onboarding.swift | 8 +- Session/Settings/NukeDataModal.swift | 2 +- Session/Utilities/BackgroundPoller.swift | 13 +- SessionMessagingKit/Calls/WebRTCSession.swift | 55 +- .../Database/Models/Attachment.swift | 5 +- .../Database/Models/BlindedIdLookup.swift | 39 +- .../Database/Models/Contact.swift | 4 +- .../Database/Models/Interaction.swift | 13 +- .../Database/Models/Profile.swift | 2 +- .../Database/Models/SessionThread.swift | 21 +- .../Jobs/Types/AttachmentDownloadJob.swift | 2 +- .../Jobs/Types/AttachmentUploadJob.swift | 7 +- .../Jobs/Types/ConfigMessageReceiveJob.swift | 2 +- .../Jobs/Types/ConfigurationSyncJob.swift | 29 +- .../Jobs/Types/DisappearingMessagesJob.swift | 2 +- .../Types/FailedAttachmentDownloadsJob.swift | 2 +- .../Jobs/Types/FailedMessageSendsJob.swift | 2 +- .../Jobs/Types/GarbageCollectionJob.swift | 12 +- .../Jobs/Types/GroupLeavingJob.swift | 7 +- .../Jobs/Types/MessageReceiveJob.swift | 2 +- .../Jobs/Types/MessageSendJob.swift | 17 +- .../Jobs/Types/NotifyPushServerJob.swift | 2 +- .../RetrieveDefaultOpenGroupRoomsJob.swift | 2 +- .../Jobs/Types/SendReadReceiptsJob.swift | 6 +- .../Jobs/Types/UpdateProfilePictureJob.swift | 8 +- SessionMessagingKit/Messages/Message.swift | 43 +- .../Crypto/OpenGroupAPI+Crypto.swift | 403 ++ .../Open Groups/Models/SOGSBatchRequest.swift | 1 - .../Open Groups/Models/SOGSMessage.swift | 14 +- .../Open Groups/OpenGroupAPI.swift | 140 +- .../Open Groups/OpenGroupManager.swift | 319 +- .../Open Groups/Types/NonceGenerator.swift | 25 - .../Open Groups/Types/PreparedSendData.swift | 4 +- .../Open Groups/Types/SodiumProtocols.swift | 108 - SessionMessagingKit/SMKDependencies.swift | 98 - .../Errors/MessageSenderError.swift | 8 +- .../MessageReceiver+Calls.swift | 14 +- .../MessageReceiver+ClosedGroups.swift | 19 +- ...essageReceiver+ConfigurationMessages.swift | 12 +- .../MessageReceiver+MessageRequests.swift | 19 +- .../MessageReceiver+VisibleMessages.swift | 25 +- .../MessageSender+ClosedGroups.swift | 125 +- .../MessageReceiver+Decryption.swift | 123 +- .../Sending & Receiving/MessageReceiver.swift | 23 +- .../MessageSender+Convenience.swift | 40 +- .../MessageSender+Encryption.swift | 62 +- .../Sending & Receiving/MessageSender.swift | 95 +- .../Pollers/ClosedGroupPoller.swift | 18 +- .../Pollers/CurrentUserPoller.swift | 14 +- .../Pollers/OpenGroupPoller.swift | 50 +- .../Sending & Receiving/Pollers/Poller.swift | 51 +- .../Typing Indicators/TypingIndicators.swift | 22 +- .../SessionUtil+UserGroups.swift | 6 +- .../SessionUtil+UserProfile.swift | 6 +- .../Crypto+SessionMessagingKit.swift | 144 + .../Utilities/ProfileManager.swift | 19 +- .../Utilities/SessionCrypto.swift | 285 - .../Jobs/Types/MessageSendJobSpec.swift | 130 +- .../Models/BatchRequestInfoSpec.swift | 30 +- .../Open Groups/Models/SOGSMessageSpec.swift | 67 +- .../Open Groups/OpenGroupAPISpec.swift | 4765 ++++++----------- .../Open Groups/OpenGroupManagerSpec.swift | 1696 +++--- .../Types/SodiumProtocolsSpec.swift | 98 - .../MessageReceiverDecryptionSpec.swift | 237 +- .../MessageSenderEncryptionSpec.swift | 218 +- .../Utilities/CryptoSMKSpec.swift | 402 ++ .../Utilities/SodiumUtilitiesSpec.swift | 352 -- .../_TestUtilities/DependencyExtensions.swift | 44 - .../MockAeadXChaCha20Poly1305Ietf.swift | 19 - .../_TestUtilities/MockBox.swift | 16 - .../_TestUtilities/MockEd25519.swift | 17 - .../_TestUtilities/MockGenericHash.swift | 20 - .../_TestUtilities/MockNonce16Generator.swift | 11 - .../_TestUtilities/MockNonce24Generator.swift | 11 - .../_TestUtilities/MockOGMCache.swift | 2 +- .../_TestUtilities/MockSign.swift | 23 - .../_TestUtilities/MockSodium.swift | 38 - .../OGMDependencyExtensions.swift | 46 - .../_TestUtilities/TestOnionRequestAPI.swift | 82 - SessionShareExtension/ThreadPickerVC.swift | 17 +- .../Models/SnodeReceivedMessageInfo.swift | 20 +- SessionSnodeKit/Jobs/GetSnodePoolJob.swift | 9 +- .../Networking/NetworkType+SnodeKit.swift | 3 - .../Networking/OnionRequestAPI.swift | 73 +- SessionSnodeKit/Networking/SnodeAPI.swift | 91 +- SessionSnodeKit/SSKDependencies.swift | 38 - ...eadDisappearingMessagesViewModelSpec.swift | 16 +- .../ThreadSettingsViewModelSpec.swift | 21 +- .../NotificationContentViewModelSpec.swift | 2 +- .../Combine/Publisher+Utilities.swift | 22 + .../Combine/RetryWithDependencies.swift | 39 + SessionUtilitiesKit/Database/Models/Job.swift | 10 +- SessionUtilitiesKit/Database/Storage.swift | 14 +- .../Utilities/Database+Utilities.swift | 4 +- SessionUtilitiesKit/General/Caches.swift | 126 + .../General/Data+Utilities.swift | 4 +- .../General/Dependencies.swift | 142 +- .../General/Dictionary+Utilities.swift | 14 + SessionUtilitiesKit/General/General.swift | 30 +- .../General/Timer+MainThread.swift | 16 +- SessionUtilitiesKit/JobRunner/JobRunner.swift | 677 ++- .../Networking/NetworkType.swift | 42 + .../Networking/SessionNetwork.swift | 3 - SessionUtilitiesKit/Utilities/Crypto.swift | 92 + .../Utilities/CryptoType.swift | 3 - .../Utilities/DispatchQueue+Utilities.swift | 35 + .../Utilities/JSONEncoder+Utilities.swift | 12 + .../Database/Models/IdentitySpec.swift | 2 +- .../PersistableRecordUtilitiesSpec.swift | 5 +- .../General/DependenciesSpec.swift | 43 + .../JobRunner/JobRunnerSpec.swift | 1496 +++--- .../Networking/BatchResponseSpec.swift | 25 +- .../AttachmentApprovalViewController.swift | 7 +- .../AttachmentTextToolbar.swift | 8 +- .../CommonMockedExtensions.swift | 38 + _SharedTestUtilities/Mock.swift | 72 +- _SharedTestUtilities/MockCaches.swift | 51 + _SharedTestUtilities/MockCrypto.swift | 24 + _SharedTestUtilities/MockGeneralCache.swift | 2 +- _SharedTestUtilities/MockJobRunner.swift | 16 +- _SharedTestUtilities/MockNetwork.swift | 114 + _SharedTestUtilities/Mocked.swift | 78 + _SharedTestUtilities/NimbleExtensions.swift | 27 +- _SharedTestUtilities/SynchronousStorage.swift | 71 +- _SharedTestUtilities/TestExtensions.swift | 9 + 139 files changed, 7211 insertions(+), 7867 deletions(-) create mode 100644 SessionMessagingKit/Open Groups/Crypto/OpenGroupAPI+Crypto.swift delete mode 100644 SessionMessagingKit/Open Groups/Types/NonceGenerator.swift delete mode 100644 SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift delete mode 100644 SessionMessagingKit/SMKDependencies.swift create mode 100644 SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift delete mode 100644 SessionMessagingKit/Utilities/SessionCrypto.swift delete mode 100644 SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift create mode 100644 SessionMessagingKitTests/Utilities/CryptoSMKSpec.swift delete mode 100644 SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockBox.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockEd25519.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockSign.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockSodium.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift delete mode 100644 SessionSnodeKit/Networking/NetworkType+SnodeKit.swift delete mode 100644 SessionSnodeKit/SSKDependencies.swift create mode 100644 SessionUtilitiesKit/Combine/RetryWithDependencies.swift create mode 100644 SessionUtilitiesKit/General/Caches.swift create mode 100644 SessionUtilitiesKit/Networking/NetworkType.swift delete mode 100644 SessionUtilitiesKit/Networking/SessionNetwork.swift create mode 100644 SessionUtilitiesKit/Utilities/Crypto.swift delete mode 100644 SessionUtilitiesKit/Utilities/CryptoType.swift create mode 100644 SessionUtilitiesKit/Utilities/DispatchQueue+Utilities.swift create mode 100644 SessionUtilitiesKit/Utilities/JSONEncoder+Utilities.swift create mode 100644 SessionUtilitiesKitTests/General/DependenciesSpec.swift create mode 100644 _SharedTestUtilities/MockCaches.swift create mode 100644 _SharedTestUtilities/MockCrypto.swift create mode 100644 _SharedTestUtilities/MockNetwork.swift create mode 100644 _SharedTestUtilities/Mocked.swift create mode 100644 _SharedTestUtilities/TestExtensions.swift diff --git a/.drone.jsonnet b/.drone.jsonnet index d1f21a6d6..29f24c2b5 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -71,7 +71,7 @@ local update_cocoapods_cache = { name: 'Run Unit Tests', commands: [ 'mkdir build', - 'NSUnbufferedIO=YES set -o pipefail && xcodebuild test -workspace Session.xcworkspace -scheme Session -destination "platform=iOS Simulator,name=iPhone 14" -destination "platform=iOS Simulator,name=iPhone 14 Pro Max" -parallel-testing-enabled YES -test-timeouts-enabled YES -maximum-test-execution-time-allowance 2 -collect-test-diagnostics never 2>&1 | ./Pods/xcbeautify/xcbeautify --is-ci --report junit --report-path ./build/reports --junit-report-filename junit2.xml' + 'NSUnbufferedIO=YES set -o pipefail && xcodebuild test -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -destination "platform=iOS Simulator,name=iPhone 14" -destination "platform=iOS Simulator,name=iPhone 14 Pro Max" -parallel-testing-enabled YES -test-timeouts-enabled YES -maximum-test-execution-time-allowance 2 -collect-test-diagnostics never 2>&1 | ./Pods/xcbeautify/xcbeautify --is-ci --report junit --report-path ./build/reports --junit-report-filename junit2.xml' ], }, update_cocoapods_cache @@ -91,7 +91,7 @@ local update_cocoapods_cache = { name: 'Build', commands: [ 'mkdir build', - 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration "App Store Release" -sdk iphonesimulator -archivePath ./build/Session_sim.xcarchive -destination "generic/platform=iOS Simulator" | ./Pods/xcbeautify/xcbeautify --is-ci' + 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -configuration "App Store Release" -sdk iphonesimulator -archivePath ./build/Session_sim.xcarchive -destination "generic/platform=iOS Simulator" | ./Pods/xcbeautify/xcbeautify --is-ci' ], }, update_cocoapods_cache, @@ -118,7 +118,7 @@ local update_cocoapods_cache = { name: 'Build', commands: [ 'mkdir build', - 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration "App Store Release" -sdk iphoneos -archivePath ./build/Session.xcarchive -destination "generic/platform=iOS" -allowProvisioningUpdates' + 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -configuration "App Store Release" -sdk iphoneos -archivePath ./build/Session.xcarchive -destination "generic/platform=iOS" -allowProvisioningUpdates CODE_SIGNING_ALLOWED=NO' ], }, update_cocoapods_cache, diff --git a/Scripts/drone-static-upload.sh b/Scripts/drone-static-upload.sh index 4fd13faa5..60906b619 100755 --- a/Scripts/drone-static-upload.sh +++ b/Scripts/drone-static-upload.sh @@ -28,13 +28,13 @@ else base="session-ios-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}" fi -mkdir -v "$base" +mkdir -vp "$base" # Copy over the build products prod_path="build/Session.xcarchive" sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app" -mkdir build +mkdir -p build echo "Test" > "build/test.txt" if [ ! -d $prod_path ]; then diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ee51b772a..121b8920a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -470,11 +470,10 @@ FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; FD078E4D27E17156000769AF /* MockOGMCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4C27E17156000769AF /* MockOGMCache.swift */; }; - FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4E27E175F1000769AF /* DependencyExtensions.swift */; }; - FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */; }; FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; - FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5927E29F09000769AF /* MockNonce16Generator.swift */; }; - FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */; }; + FD0969F92A69FFE700C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; + FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; + FD0969FB2A6A00B100C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; FD09796B27F6C67500936362 /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796A27F6C67500936362 /* Failable.swift */; }; FD09796E27FA6D0000936362 /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796D27FA6D0000936362 /* Contact.swift */; }; FD09797027FA6FF300936362 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796F27FA6FF300936362 /* Profile.swift */; }; @@ -532,6 +531,21 @@ 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 */; }; + FD23CE1B2A651E6D0000B97C /* NetworkType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1A2A651E6D0000B97C /* NetworkType.swift */; }; + FD23CE1F2A65269C0000B97C /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1E2A65269C0000B97C /* Crypto.swift */; }; + FD23CE222A661D000000B97C /* OpenGroupAPI+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */; }; + FD23CE242A675C440000B97C /* Crypto+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE232A675C440000B97C /* Crypto+SessionMessagingKit.swift */; }; + FD23CE262A676B5B0000B97C /* DependenciesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */; }; + FD23CE282A67755C0000B97C /* MockCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE272A67755C0000B97C /* MockCrypto.swift */; }; + FD23CE292A6775650000B97C /* MockCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE272A67755C0000B97C /* MockCrypto.swift */; }; + FD23CE2A2A6775660000B97C /* MockCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE272A67755C0000B97C /* MockCrypto.swift */; }; + FD23CE2C2A678DF80000B97C /* MockCaches.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE2B2A678DF80000B97C /* MockCaches.swift */; }; + FD23CE2D2A678E1E0000B97C /* MockCaches.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE2B2A678DF80000B97C /* MockCaches.swift */; }; + FD23CE2E2A678E1E0000B97C /* MockCaches.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE2B2A678DF80000B97C /* MockCaches.swift */; }; + FD23CE302A67B8820000B97C /* Caches.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE2F2A67B8820000B97C /* Caches.swift */; }; + FD23CE332A67C4D90000B97C /* MockNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE312A67C38D0000B97C /* MockNetwork.swift */; }; + FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE312A67C38D0000B97C /* MockNetwork.swift */; }; + FD23CE352A67C4DA0000B97C /* MockNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE312A67C38D0000B97C /* MockNetwork.swift */; }; FD23EA5C28ED00F80058676E /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; }; FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; @@ -615,9 +629,8 @@ FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */; }; FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */; }; FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */; }; - FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */; }; + FD3C906A27E417CE00CD579F /* CryptoSMKSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906927E417CE00CD579F /* CryptoSMKSpec.swift */; }; FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */; }; - FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906E27E43E8700CD579F /* MockBox.swift */; }; FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; }; @@ -634,6 +647,7 @@ FD52090528B4915F006098F6 /* PrivacySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */; }; FD52090928B59411006098F6 /* ScreenLockUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090828B59411006098F6 /* ScreenLockUI.swift */; }; FD52090B28B59BB4006098F6 /* ScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090A28B59BB4006098F6 /* ScreenLockViewController.swift */; }; + FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */; }; FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */; }; FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */; }; FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */; }; @@ -696,7 +710,6 @@ FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */; }; FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; }; FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; - FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD772899284AF1BD0018502F /* Sodium+Utilities.swift */; }; FD77289E284EF1C50018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD77289D284EF1C50018502F /* Sodium+Utilities.swift */; }; FD778B6429B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */; }; FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; platformFilter = ios; }; @@ -708,6 +721,7 @@ FD83B9C927D0487A005E1583 /* SendDirectMessageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */; }; FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */; }; FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.swift */; }; + FD83DCDD2A739D350065FFAE /* RetryWithDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */; }; FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; }; @@ -716,11 +730,6 @@ FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9728422F1A000E298B /* Date+Utilities.swift */; }; FD848B9A28442CE6000E298B /* StorageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9928442CE6000E298B /* StorageError.swift */; }; FD848B9C284435D7000E298B /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9B284435D7000E298B /* AppSetup.swift */; }; - FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF327C2F49200510D0C /* MockSodium.swift */; }; - FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF527C2F52C00510D0C /* MockSign.swift */; }; - FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */; }; - FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF927C2F5C500510D0C /* MockGenericHash.swift */; }; - FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFB27C2F60700510D0C /* MockEd25519.swift */; }; FD87DCFA28B74DB300AF0F98 /* ConversationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */; }; FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */; }; FD87DD0028B820F200AF0F98 /* BlockedContactCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD87DCFF28B820F200AF0F98 /* BlockedContactCell.swift */; }; @@ -745,6 +754,9 @@ FD9B30F3293EA0BF008DEE3E /* BatchResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9B30F2293EA0BF008DEE3E /* BatchResponseSpec.swift */; }; FD9BDE002A5D22B7005F1EBC /* libSessionUtil.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FD9BDDF82A5D2294005F1EBC /* libSessionUtil.a */; }; FD9BDE012A5D24EA005F1EBC /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; + FD9DD2712A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; + FD9DD2722A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; + FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FDA1E83629A5748F00C5C3BD /* ConfigUserGroupsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA1E83529A5748F00C5C3BD /* ConfigUserGroupsSpec.swift */; }; FDA1E83929A5771A00C5C3BD /* LibSessionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA1E83829A5771A00C5C3BD /* LibSessionSpec.swift */; }; FDA1E83B29A5F2D500C5C3BD /* SessionUtil+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA1E83A29A5F2D500C5C3BD /* SessionUtil+Shared.swift */; }; @@ -769,14 +781,11 @@ FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */; }; FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */; }; FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */; }; - FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */; }; FDC290A627D860CE005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; }; FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; }; - FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */; }; FDC4380927B31D4E00C60D73 /* OpenGroupAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */; }; - FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381427B329CE00C60D73 /* NonceGenerator.swift */; }; FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381627B32EC700C60D73 /* Personalization.swift */; }; FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */; }; FDC4382F27B383AF00C60D73 /* PushServerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */; }; @@ -796,8 +805,6 @@ FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */; }; FDC438AA27BB12BB00C60D73 /* UserModeratorRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */; }; FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438BC27BB2AB400C60D73 /* Mockable.swift */; }; - FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */; }; - FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C227BB512200C60D73 /* SodiumProtocols.swift */; }; FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C627BB6DF000C60D73 /* DirectMessage.swift */; }; FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */; }; FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */; }; @@ -816,8 +823,8 @@ FDDCBDAB29E776BF00303C38 /* seed2-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA529E776BF00303C38 /* seed2-2023-2y.der */; }; FDDCBDAC29E776BF00303C38 /* seed3-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA629E776BF00303C38 /* seed3-2023-2y.crt */; }; FDDCBDAD29E776BF00303C38 /* seed3-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */; }; - FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; }; + FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; FDE658A129418C7900A33BC1 /* CryptoKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE658A029418C7900A33BC1 /* CryptoKit+Utilities.swift */; }; FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE658A229418E2F00A33BC1 /* KeyPair.swift */; }; FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE6E99729F8E63A00F93C5D /* Accessibility.swift */; }; @@ -852,7 +859,6 @@ FDF8488629405A61007DCAE5 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8488529405A60007DCAE5 /* Request.swift */; }; FDF8488829405A9A007DCAE5 /* SOGSBatchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8488729405A9A007DCAE5 /* SOGSBatchRequest.swift */; }; FDF8488929405B27007DCAE5 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */; }; - FDF8488B29405BF2007DCAE5 /* SSKDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8488A29405BF2007DCAE5 /* SSKDependencies.swift */; }; FDF8488E29405C04007DCAE5 /* GetSnodePoolJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8488D29405C04007DCAE5 /* GetSnodePoolJob.swift */; }; FDF8489129405C13007DCAE5 /* SnodeAPINamespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489029405C13007DCAE5 /* SnodeAPINamespace.swift */; }; FDF8489429405C1B007DCAE5 /* SnodeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8489329405C1B007DCAE5 /* SnodeAPI.swift */; }; @@ -911,6 +917,7 @@ FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE127282D05530098B17F /* MediaPresentationContext.swift */; }; FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */; }; FDFF61D729F2600300F95FB0 /* Identity+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFF61D629F2600300F95FB0 /* Identity+Utilities.swift */; }; + FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */; }; FE5FDED6D91BB4B3FA5C104D /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A9C113D2086D3C8A68A371C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */; }; /* End PBXBuildFile section */ @@ -1626,10 +1633,7 @@ FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; FD078E4727E02561000769AF /* CommonMockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonMockedExtensions.swift; sourceTree = ""; }; FD078E4C27E17156000769AF /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; - FD078E4E27E175F1000769AF /* DependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyExtensions.swift; sourceTree = ""; }; - FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGMDependencyExtensions.swift; sourceTree = ""; }; - FD078E5927E29F09000769AF /* MockNonce16Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce16Generator.swift; sourceTree = ""; }; - FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNonce24Generator.swift; sourceTree = ""; }; + FD0969F82A69FFE700C5C365 /* Mocked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mocked.swift; sourceTree = ""; }; FD09796A27F6C67500936362 /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; FD09796D27FA6D0000936362 /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; FD09796F27FA6FF300936362 /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = ""; }; @@ -1685,6 +1689,15 @@ 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 = ""; }; + FD23CE1A2A651E6D0000B97C /* NetworkType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkType.swift; sourceTree = ""; }; + FD23CE1E2A65269C0000B97C /* Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; }; + FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPI+Crypto.swift"; sourceTree = ""; }; + FD23CE232A675C440000B97C /* Crypto+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Crypto+SessionMessagingKit.swift"; sourceTree = ""; }; + FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependenciesSpec.swift; sourceTree = ""; }; + FD23CE272A67755C0000B97C /* MockCrypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCrypto.swift; sourceTree = ""; }; + FD23CE2B2A678DF80000B97C /* MockCaches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCaches.swift; sourceTree = ""; }; + FD23CE2F2A67B8820000B97C /* Caches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Caches.swift; sourceTree = ""; }; + FD23CE312A67C38D0000B97C /* MockNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetwork.swift; sourceTree = ""; }; FD23EA6028ED0B260058676E /* CombineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExtensions.swift; sourceTree = ""; }; FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; @@ -1736,9 +1749,8 @@ FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = ""; }; FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = ""; }; FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookupSpec.swift; sourceTree = ""; }; - FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumUtilitiesSpec.swift; sourceTree = ""; }; + FD3C906927E417CE00CD579F /* CryptoSMKSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSMKSpec.swift; sourceTree = ""; }; FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderEncryptionSpec.swift; sourceTree = ""; }; - FD3C906E27E43E8700CD579F /* MockBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBox.swift; sourceTree = ""; }; FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; @@ -1754,6 +1766,7 @@ FD52090628B49738006098F6 /* ConfirmationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationModal.swift; sourceTree = ""; }; FD52090828B59411006098F6 /* ScreenLockUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockUI.swift; sourceTree = ""; }; FD52090A28B59BB4006098F6 /* ScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockViewController.swift; sourceTree = ""; }; + FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Utilities.swift"; sourceTree = ""; }; FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ReadReceipts.swift"; sourceTree = ""; }; FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+TypingIndicators.swift"; sourceTree = ""; }; FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+DataExtractionNotification.swift"; sourceTree = ""; }; @@ -1811,7 +1824,6 @@ FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModelSpec.swift; sourceTree = ""; }; FD7728952849E7E90018502F /* String+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; - FD772899284AF1BD0018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; FD77289D284EF1C50018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _014_GenerateInitialUserConfigDumps.swift; sourceTree = ""; }; FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionUtilitiesKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1822,6 +1834,7 @@ FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageResponse.swift; sourceTree = ""; }; FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FSEndpoint.swift; sourceTree = ""; }; FD83B9D127D59495005E1583 /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = ""; }; + FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryWithDependencies.swift; sourceTree = ""; }; FD848B86283B844B000E298B /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = ""; }; FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; @@ -1833,11 +1846,6 @@ FD859EEF27BF207700510D0C /* SessionProtos.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = SessionProtos.proto; sourceTree = ""; }; FD859EF027BF207C00510D0C /* WebSocketResources.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = WebSocketResources.proto; sourceTree = ""; }; FD859EF127BF6BA200510D0C /* Data+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; - FD859EF327C2F49200510D0C /* MockSodium.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSodium.swift; sourceTree = ""; }; - FD859EF527C2F52C00510D0C /* MockSign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSign.swift; sourceTree = ""; }; - FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAeadXChaCha20Poly1305Ietf.swift; sourceTree = ""; }; - FD859EF927C2F5C500510D0C /* MockGenericHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGenericHash.swift; sourceTree = ""; }; - FD859EFB27C2F60700510D0C /* MockEd25519.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockEd25519.swift; sourceTree = ""; }; FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSettingsViewModel.swift; sourceTree = ""; }; FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactsViewModel.swift; sourceTree = ""; }; FD87DCFF28B820F200AF0F98 /* BlockedContactCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactCell.swift; sourceTree = ""; }; @@ -1858,6 +1866,7 @@ FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadMarkerCell.swift; sourceTree = ""; }; FD9B30F2293EA0BF008DEE3E /* BatchResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchResponseSpec.swift; sourceTree = ""; }; FD9BDDF82A5D2294005F1EBC /* libSessionUtil.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libSessionUtil.a; sourceTree = BUILT_PRODUCTS_DIR; }; + FD9DD2702A72516D00ECB68E /* TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExtensions.swift; sourceTree = ""; }; FDA1E83529A5748F00C5C3BD /* ConfigUserGroupsSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigUserGroupsSpec.swift; sourceTree = ""; }; FDA1E83829A5771A00C5C3BD /* LibSessionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionSpec.swift; sourceTree = ""; }; FDA1E83A29A5F2D500C5C3BD /* SessionUtil+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Shared.swift"; sourceTree = ""; }; @@ -1882,13 +1891,10 @@ FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSErrorSpec.swift; sourceTree = ""; }; FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalizationSpec.swift; sourceTree = ""; }; FDC2909927D71376005DAE71 /* NonceGeneratorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGeneratorSpec.swift; sourceTree = ""; }; - FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocolsSpec.swift; sourceTree = ""; }; FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerSpec.swift; sourceTree = ""; }; FDC290A527D860CE005DAE71 /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; - FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestOnionRequestAPI.swift; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPIError.swift; sourceTree = ""; }; - FDC4381427B329CE00C60D73 /* NonceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonceGenerator.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpoint.swift; sourceTree = ""; }; FDC4382E27B383AF00C60D73 /* PushServerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushServerResponse.swift; sourceTree = ""; }; @@ -1910,8 +1916,6 @@ FDC438B027BB159600C60D73 /* RequestInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInfo.swift; sourceTree = ""; }; FDC438B227BB15B400C60D73 /* ResponseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseInfo.swift; sourceTree = ""; }; FDC438BC27BB2AB400C60D73 /* Mockable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mockable.swift; sourceTree = ""; }; - FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKDependencies.swift; sourceTree = ""; }; - FDC438C227BB512200C60D73 /* SodiumProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumProtocols.swift; sourceTree = ""; }; FDC438C627BB6DF000C60D73 /* DirectMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessage.swift; sourceTree = ""; }; FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageRequest.swift; sourceTree = ""; }; FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateMessageRequest.swift; sourceTree = ""; }; @@ -1929,8 +1933,8 @@ FDDCBDA529E776BF00303C38 /* seed2-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed2-2023-2y.der"; sourceTree = ""; }; FDDCBDA629E776BF00303C38 /* seed3-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed3-2023-2y.crt"; sourceTree = ""; }; FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed3-2023-2y.der"; sourceTree = ""; }; - FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchRequest+Utilities.swift"; sourceTree = ""; }; + FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; FDE658A029418C7900A33BC1 /* CryptoKit+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CryptoKit+Utilities.swift"; sourceTree = ""; }; FDE658A229418E2F00A33BC1 /* KeyPair.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyPair.swift; sourceTree = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = ""; }; @@ -1968,7 +1972,6 @@ FDF8488229405A12007DCAE5 /* BatchResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatchResponse.swift; sourceTree = ""; }; FDF8488529405A60007DCAE5 /* Request.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; FDF8488729405A9A007DCAE5 /* SOGSBatchRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SOGSBatchRequest.swift; sourceTree = ""; }; - FDF8488A29405BF2007DCAE5 /* SSKDependencies.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKDependencies.swift; sourceTree = ""; }; FDF8488D29405C04007DCAE5 /* GetSnodePoolJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetSnodePoolJob.swift; sourceTree = ""; }; FDF8489029405C13007DCAE5 /* SnodeAPINamespace.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAPINamespace.swift; sourceTree = ""; }; FDF8489329405C1B007DCAE5 /* SnodeAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAPI.swift; sourceTree = ""; }; @@ -2026,6 +2029,7 @@ FDFDE127282D05530098B17F /* MediaPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPresentationContext.swift; sourceTree = ""; }; FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaZoomAnimationController.swift; sourceTree = ""; }; FDFF61D629F2600300F95FB0 /* Identity+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Identity+Utilities.swift"; sourceTree = ""; }; + FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONEncoder+Utilities.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2556,6 +2560,7 @@ FDC438B227BB15B400C60D73 /* ResponseInfo.swift */, C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */, C3C2A5BC255385EE00C340D1 /* HTTP.swift */, + FD23CE1A2A651E6D0000B97C /* NetworkType.swift */, FDF848EE294067E4007DCAE5 /* URLResponse+Utilities.swift */, ); path = Networking; @@ -2583,14 +2588,15 @@ isa = PBXGroup; children = ( 7BD477A727EC39F5004E2822 /* Atomic.swift */, + FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */, C33FDB8A255A581200E217F9 /* AppContext.h */, C33FDB85255A581100E217F9 /* AppContext.m */, C3C2A5D12553860800C340D1 /* Array+Utilities.swift */, FDC4383D27B4708600C60D73 /* Atomic.swift */, - FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Utilities.swift */, + FD23CE2F2A67B8820000B97C /* Caches.swift */, FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */, FDC6D75F2862B3F600B04575 /* Dependencies.swift */, C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, @@ -3180,6 +3186,7 @@ C3A721332558BDDF0043A11F /* Open Groups */ = { isa = PBXGroup; children = ( + FD23CE202A661CE80000B97C /* Crypto */, FDC4381827B34EAD00C60D73 /* Models */, FDC4380727B31D3A00C60D73 /* Types */, FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */, @@ -3204,9 +3211,10 @@ children = ( C33FDB01255A580700E217F9 /* AppReadiness.h */, C33FDB75255A581000E217F9 /* AppReadiness.m */, - FDF0B7542807C4BB004C14C5 /* Environment.swift */, + FD23CE232A675C440000B97C /* Crypto+SessionMessagingKit.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, + FDF0B7542807C4BB004C14C5 /* Environment.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, @@ -3220,7 +3228,6 @@ C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, FDC4386827B4E6B700C60D73 /* String+Utlities.swift */, - FD772899284AF1BD0018502F /* Sodium+Utilities.swift */, FD16AB602A1DD9B60083D849 /* ProfilePictureView+Convenience.swift */, C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */, C3ECBF7A257056B700EA7FCE /* Threading.swift */, @@ -3239,7 +3246,6 @@ FDF8488C29405C04007DCAE5 /* Jobs */, FDF8489229405C1B007DCAE5 /* Networking */, C3C2A5CD255385F300C340D1 /* Utilities */, - FDF8488A29405BF2007DCAE5 /* SSKDependencies.swift */, C3C2A5B9255385ED00C340D1 /* Configuration.swift */, ); path = SessionSnodeKit; @@ -3306,7 +3312,6 @@ FD8ECF7529340F4800C0D1BB /* SessionUtil */, FD3E0C82283B581F002A425C /* Shared Models */, C3BBE0B32554F0D30050F1E3 /* Utilities */, - FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */, FD245C612850664300B966DD /* Configuration.swift */, ); path = SessionMessagingKit; @@ -3552,7 +3557,10 @@ FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */, FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */, FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */, + FD23CE1E2A65269C0000B97C /* Crypto.swift */, + FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */, FD09796A27F6C67500936362 /* Failable.swift */, + FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */, FD09797127FAA2F500936362 /* Optional+Utilities.swift */, FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */, @@ -3736,6 +3744,14 @@ path = Utilities; sourceTree = ""; }; + FD23CE202A661CE80000B97C /* Crypto */ = { + isa = PBXGroup; + children = ( + FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */, + ); + path = Crypto; + sourceTree = ""; + }; FD29598E2A43BE5400888A17 /* Utilities */ = { isa = PBXGroup; children = ( @@ -3838,7 +3854,7 @@ FD3C906827E417B100CD579F /* Utilities */ = { isa = PBXGroup; children = ( - FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */, + FD3C906927E417CE00CD579F /* CryptoSMKSpec.swift */, ); path = Utilities; sourceTree = ""; @@ -3876,6 +3892,7 @@ children = ( FD7115F628C8150D00B47552 /* Disposable Views */, FD7115FD28C8202D00B47552 /* ReplaySubject.swift */, + FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */, FD7115FB28C8155800B47552 /* Publisher+Utilities.swift */, FD71160128C8255900B47552 /* UIControl+Combine.swift */, FD7115F928C8153400B47552 /* UIBarButtonItem+Combine.swift */, @@ -4011,6 +4028,7 @@ isa = PBXGroup; children = ( FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */, + FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */, FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */, ); path = General; @@ -4020,9 +4038,14 @@ isa = PBXGroup; children = ( FDC290A527D860CE005DAE71 /* Mock.swift */, + FD0969F82A69FFE700C5C365 /* Mocked.swift */, + FD23CE272A67755C0000B97C /* MockCrypto.swift */, + FD23CE2B2A678DF80000B97C /* MockCaches.swift */, FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, + FD23CE312A67C38D0000B97C /* MockNetwork.swift */, FD96F3A629DBD43D00401309 /* MockJobRunner.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, + FD9DD2702A72516D00ECB68E /* TestExtensions.swift */, FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */, FD078E4727E02561000769AF /* CommonMockedExtensions.swift */, FD23EA6028ED0B260058676E /* CombineExtensions.swift */, @@ -4141,7 +4164,6 @@ FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */, FDC2909327D710B4005DAE71 /* SOGSEndpointSpec.swift */, FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */, - FDC2909B27D713D2005DAE71 /* SodiumProtocolsSpec.swift */, ); path = Types; sourceTree = ""; @@ -4155,8 +4177,6 @@ FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */, FDC4381627B32EC700C60D73 /* Personalization.swift */, FD2959912A4417A900888A17 /* PreparedSendData.swift */, - FDC4381427B329CE00C60D73 /* NonceGenerator.swift */, - FDC438C227BB512200C60D73 /* SodiumProtocols.swift */, ); path = Types; sourceTree = ""; @@ -4248,19 +4268,8 @@ isa = PBXGroup; children = ( FDC438BC27BB2AB400C60D73 /* Mockable.swift */, - FD859EF327C2F49200510D0C /* MockSodium.swift */, - FD3C906E27E43E8700CD579F /* MockBox.swift */, - FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, - FD859EF527C2F52C00510D0C /* MockSign.swift */, - FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */, - FD859EFB27C2F60700510D0C /* MockEd25519.swift */, - FD078E5927E29F09000769AF /* MockNonce16Generator.swift */, - FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */, FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, FD078E4C27E17156000769AF /* MockOGMCache.swift */, - FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */, - FD078E4E27E175F1000769AF /* DependencyExtensions.swift */, - FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */, ); path = _TestUtilities; sourceTree = ""; @@ -4427,9 +4436,6 @@ C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */, C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */, C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */, - C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */, - C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */, - C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */, C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */, C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */, C33FDDB3255A582000E217F9 /* OWSError.h in Headers */, @@ -4631,9 +4637,9 @@ D221A085169C9E5E00537ABF /* Sources */, D221A086169C9E5E00537ABF /* Frameworks */, D221A087169C9E5E00537ABF /* Resources */, - FDD82C422A2085B900425F05 /* Add Commit Hash To Build Info Plist */, 453518771FC635DD00210559 /* Embed Foundation Extensions */, 4535189F1FC63DBF00210559 /* Embed Frameworks */, + FDD82C422A2085B900425F05 /* Add Commit Hash To Build Info Plist */, 90DF4725BB1271EBA2C66A12 /* [CP] Embed Pods Frameworks */, ); buildRules = ( @@ -5557,7 +5563,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FDF8488B29405BF2007DCAE5 /* SSKDependencies.swift in Sources */, FDF8488E29405C04007DCAE5 /* GetSnodePoolJob.swift in Sources */, FDF848C329405C5A007DCAE5 /* DeleteMessagesRequest.swift in Sources */, FDF8489129405C13007DCAE5 /* SnodeAPINamespace.swift in Sources */, @@ -5629,10 +5634,13 @@ FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */, + FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */, FDF8488329405A12007DCAE5 /* BatchResponse.swift in Sources */, C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */, + FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */, C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */, FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */, + FD23CE1B2A651E6D0000B97C /* NetworkType.swift in Sources */, FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */, FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */, FDBB25E32988B13800F1508E /* _004_AddJobPriority.swift in Sources */, @@ -5690,8 +5698,10 @@ C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */, B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */, 7BD477A827EC39F5004E2822 /* Atomic.swift in Sources */, + FD23CE1F2A65269C0000B97C /* Crypto.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, FDF8488629405A61007DCAE5 /* Request.swift in Sources */, + FD23CE302A67B8820000B97C /* Caches.swift in Sources */, FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */, FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */, @@ -5701,6 +5711,7 @@ FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */, B88FA7FB26114EA70049422F /* Hex.swift in Sources */, FD7728962849E7E90018502F /* String+Utilities.swift in Sources */, + FD83DCDD2A739D350065FFAE /* RetryWithDependencies.swift in Sources */, C35D0DB525AE5F1200B6BF49 /* UIEdgeInsets.swift in Sources */, C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */, FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */, @@ -5753,7 +5764,6 @@ FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, FD3003662A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift in Sources */, 7B81682C28B72F480069F315 /* PendingChange.swift in Sources */, - FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, FD71161C28D194FB00B47552 /* MentionInfo.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, @@ -5851,9 +5861,9 @@ FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */, FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */, + FD23CE222A661D000000B97C /* OpenGroupAPI+Crypto.swift in Sources */, FD245C652850665400B966DD /* ClosedGroupControlMessage.swift in Sources */, FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */, - FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */, 7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */, FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */, C32C5A88256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift in Sources */, @@ -5882,7 +5892,7 @@ FDA1E83B29A5F2D500C5C3BD /* SessionUtil+Shared.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, FD16AB612A1DD9B60083D849 /* ProfilePictureView+Convenience.swift in Sources */, - FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */, + FD23CE242A675C440000B97C /* Crypto+SessionMessagingKit.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */, FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */, @@ -5914,7 +5924,6 @@ FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, - FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */, FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */, B806ECA126C4A7E4008BDA44 /* WebRTCSession+UI.swift in Sources */, FD432432299C6933008A0213 /* _011_AddPendingReadReceipts.swift in Sources */, @@ -6140,14 +6149,18 @@ files = ( FD71161728D00DA400B47552 /* ThreadSettingsViewModelSpec.swift in Sources */, FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */, + FD23CE292A6775650000B97C /* MockCrypto.swift in Sources */, + FD23CE332A67C4D90000B97C /* MockNetwork.swift in Sources */, FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */, + FD23CE2D2A678E1E0000B97C /* MockCaches.swift in Sources */, FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */, FD23EA5F28ED00FF0058676E /* CommonMockedExtensions.swift in Sources */, - FD96F3A829DBD4AD00401309 /* MockJobRunner.swift in Sources */, FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */, FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */, + FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */, FD23EA5C28ED00F80058676E /* Mock.swift in Sources */, FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */, + FD9DD2712A72516D00ECB68E /* TestExtensions.swift in Sources */, FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -6157,18 +6170,23 @@ buildActionMask = 2147483647; files = ( FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */, - FD96F3A929DBD4AD00401309 /* MockJobRunner.swift in Sources */, FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */, FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */, + FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */, FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */, + FD23CE352A67C4DA0000B97C /* MockNetwork.swift in Sources */, + FD23CE282A67755C0000B97C /* MockCrypto.swift in Sources */, FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */, FD9B30F3293EA0BF008DEE3E /* BatchResponseSpec.swift in Sources */, FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */, + FD23CE2C2A678DF80000B97C /* MockCaches.swift in Sources */, FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */, FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */, + FD23CE262A676B5B0000B97C /* DependenciesSpec.swift in Sources */, FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */, + FD0969FB2A6A00B100C5C365 /* Mocked.swift in Sources */, FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */, FD2959902A43BE5F00888A17 /* VersionSpec.swift in Sources */, ); @@ -6180,56 +6198,49 @@ files = ( FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, FDDC08F229A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift in Sources */, - FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */, - FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, - FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */, + FD3C906A27E417CE00CD579F /* CryptoSMKSpec.swift in Sources */, FD96F3A729DBD43D00401309 /* MockJobRunner.swift in Sources */, FDBB25E12983909300F1508E /* ConfigConvoInfoVolatileSpec.swift in Sources */, FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, - FD078E4F27E175F1000769AF /* DependencyExtensions.swift in Sources */, - FDC2909C27D713D2005DAE71 /* SodiumProtocolsSpec.swift in Sources */, FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */, - FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, + FD23CE2A2A6775660000B97C /* MockCrypto.swift in Sources */, FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, - FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */, FD078E4D27E17156000769AF /* MockOGMCache.swift in Sources */, - FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */, + FD9DD2722A72516D00ECB68E /* TestExtensions.swift in Sources */, FDA1E83629A5748F00C5C3BD /* ConfigUserGroupsSpec.swift in Sources */, - FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */, FDC290A627D860CE005DAE71 /* Mock.swift in Sources */, FD2B4AFB29429D1000AB4848 /* ConfigContactsSpec.swift in Sources */, FDA1E83D29AC71A800C5C3BD /* SessionUtilSpec.swift in Sources */, FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, - FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */, FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */, + FD23CE2E2A678E1E0000B97C /* MockCaches.swift in Sources */, FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */, FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, + FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */, FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, FD8ECF822934387A00C0D1BB /* ConfigUserProfileSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */, + FD0969F92A69FFE700C5C365 /* Mocked.swift in Sources */, FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FDA1E83929A5771A00C5C3BD /* LibSessionSpec.swift in Sources */, - FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */, - FD078E5A27E29F09000769AF /* MockNonce16Generator.swift in Sources */, FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, - FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index f71cfde88..3fb3d0fb8 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -35,15 +35,15 @@ extension ContextMenuVC { // MARK: - Actions - static func info(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func info(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_info"), title: "context_menu_info".localized(), accessibilityLabel: "Message info" - ) { delegate?.info(cellViewModel) } + ) { delegate?.info(cellViewModel, using: dependencies) } } - static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(systemName: "arrow.triangle.2.circlepath"), title: (cellViewModel.state == .failedToSync ? @@ -51,23 +51,23 @@ extension ContextMenuVC { "context_menu_resend".localized() ), accessibilityLabel: (cellViewModel.state == .failedToSync ? "Resync message" : "Resend message") - ) { delegate?.retry(cellViewModel) } + ) { delegate?.retry(cellViewModel, using: dependencies) } } - static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_reply"), title: "context_menu_reply".localized(), accessibilityLabel: "Reply to message" - ) { delegate?.reply(cellViewModel) } + ) { delegate?.reply(cellViewModel, using: dependencies) } } - static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "copy".localized(), accessibilityLabel: "Copy text" - ) { delegate?.copy(cellViewModel) } + ) { delegate?.copy(cellViewModel, using: dependencies) } } static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -79,50 +79,50 @@ extension ContextMenuVC { ) { delegate?.copySessionID(cellViewModel) } } - static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_trash"), title: "TXT_DELETE_TITLE".localized(), accessibilityLabel: "Delete message" - ) { delegate?.delete(cellViewModel) } + ) { delegate?.delete(cellViewModel, using: dependencies) } } - static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_download"), title: "context_menu_save".localized(), accessibilityLabel: "Save attachment" - ) { delegate?.save(cellViewModel) } + ) { delegate?.save(cellViewModel, using: dependencies) } } - static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_user".localized(), accessibilityLabel: "Ban user" - ) { delegate?.ban(cellViewModel) } + ) { delegate?.ban(cellViewModel, using: dependencies) } } - static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( icon: UIImage(named: "ic_block"), title: "context_menu_ban_and_delete_all".localized(), accessibilityLabel: "Ban user and delete" - ) { delegate?.banAndDeleteAllMessages(cellViewModel) } + ) { delegate?.banAndDeleteAllMessages(cellViewModel, using: dependencies) } } - static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action { + static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( title: emoji.rawValue, isEmojiAction: true - ) { delegate?.react(cellViewModel, with: emoji) } + ) { delegate?.react(cellViewModel, with: emoji, using: dependencies) } } - static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action { return Action( isEmojiPlus: true, accessibilityLabel: "Add emoji" - ) { delegate?.showFullEmojiKeyboard(cellViewModel) } + ) { delegate?.showFullEmojiKeyboard(cellViewModel, using: dependencies) } } static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action { @@ -150,7 +150,8 @@ extension ContextMenuVC { currentUserBlinded25PublicKey: String?, currentUserIsOpenGroupModerator: Bool, currentThreadIsMessageRequest: Bool, - delegate: ContextMenuActionDelegate? + delegate: ContextMenuActionDelegate?, + using dependencies: Dependencies = Dependencies() ) -> [Action]? { switch cellViewModel.variant { case .standardIncomingDeleted, .infoCall, @@ -159,7 +160,7 @@ extension ContextMenuVC { .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving, .infoMessageRequestAccepted, .infoDisappearingMessagesUpdate: // Let the user delete info messages and unsent messages - return [ Action.delete(cellViewModel, delegate) ] + return [ Action.delete(cellViewModel, delegate, using: dependencies) ] case .standardOutgoing, .standardIncoming: break } @@ -227,18 +228,21 @@ extension ContextMenuVC { let shouldShowInfo: Bool = (cellViewModel.attachments?.isEmpty == false) let generatedActions: [Action] = [ - (canRetry ? Action.retry(cellViewModel, delegate) : nil), - (viewModelCanReply(cellViewModel) ? Action.reply(cellViewModel, delegate) : nil), - (canCopy ? Action.copy(cellViewModel, delegate) : nil), - (canSave ? Action.save(cellViewModel, delegate) : nil), + (canRetry ? Action.retry(cellViewModel, delegate, using: dependencies) : nil), + (viewModelCanReply(cellViewModel) ? Action.reply(cellViewModel, delegate, using: dependencies) : nil), + (canCopy ? Action.copy(cellViewModel, delegate, using: dependencies) : nil), + (canSave ? Action.save(cellViewModel, delegate, using: dependencies) : nil), (canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil), - (canDelete ? Action.delete(cellViewModel, delegate) : nil), - (canBan ? Action.ban(cellViewModel, delegate) : nil), - (canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil), - (shouldShowInfo ? Action.info(cellViewModel, delegate) : nil), + (canDelete ? Action.delete(cellViewModel, delegate, using: dependencies) : nil), + (canBan ? Action.ban(cellViewModel, delegate, using: dependencies) : nil), + (canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate, using: dependencies) : nil), + (shouldShowInfo ? Action.info(cellViewModel, delegate, using: dependencies) : nil), ] - .appending(contentsOf: (shouldShowEmojiActions ? recentEmojis : []).map { Action.react(cellViewModel, $0, delegate) }) - .appending(Action.emojiPlusButton(cellViewModel, delegate)) + .appending( + contentsOf: (shouldShowEmojiActions ? recentEmojis : []) + .map { Action.react(cellViewModel, $0, delegate, using: dependencies) } + ) + .appending(Action.emojiPlusButton(cellViewModel, delegate, using: dependencies)) .compactMap { $0 } guard !generatedActions.isEmpty else { return [] } @@ -250,16 +254,16 @@ extension ContextMenuVC { // MARK: - Delegate protocol ContextMenuActionDelegate { - func info(_ cellViewModel: MessageViewModel) - func retry(_ cellViewModel: MessageViewModel) - func reply(_ cellViewModel: MessageViewModel) - func copy(_ cellViewModel: MessageViewModel) + func info(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func retry(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func reply(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func copy(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) func copySessionID(_ cellViewModel: MessageViewModel) - func delete(_ cellViewModel: MessageViewModel) - func save(_ cellViewModel: MessageViewModel) - func ban(_ cellViewModel: MessageViewModel) - func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) - func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) - func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) + func delete(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func save(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func ban(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) + func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones, using dependencies: Dependencies) + func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) func contextMenuDismissed() } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index f597cc418..947fc9201 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -173,8 +173,8 @@ extension ConversationVC: // MARK: - AttachmentApprovalViewControllerDelegate - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { - sendMessage(text: (messageText ?? ""), attachments: attachments) + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?, using dependencies: Dependencies) { + sendMessage(text: (messageText ?? ""), attachments: attachments, using: dependencies) resetMentions() dismiss(animated: true) { [weak self] in @@ -409,7 +409,8 @@ extension ConversationVC: attachments: [SignalAttachment] = [], linkPreviewDraft: LinkPreviewDraft? = nil, quoteModel: QuotedReplyModel? = nil, - hasPermissionToSendSeed: Bool = false + hasPermissionToSendSeed: Bool = false, + using dependencies: Dependencies = Dependencies() ) { guard !showBlockedModalIfNeeded() else { return } @@ -488,7 +489,7 @@ extension ConversationVC: let quoteThumbnailAttachment: Attachment? = quoteModel?.attachment?.cloneAsQuoteThumbnail() // Actually send the message - Storage.shared + dependencies.storage .writePublisher { [weak self] db in // Update the thread to be visible (if it isn't already) if self?.viewModel.threadData.threadShouldBeVisible == false { @@ -536,7 +537,8 @@ extension ConversationVC: db, interaction: insertedInteraction, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) @@ -787,10 +789,14 @@ extension ConversationVC: self.contextMenuWindow?.makeKeyAndVisible() } - func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) { + func handleItemTapped( + _ cellViewModel: MessageViewModel, + gestureRecognizer: UITapGestureRecognizer, + using dependencies: Dependencies = Dependencies() + ) { guard cellViewModel.variant != .standardOutgoing || (cellViewModel.state != .failed && cellViewModel.state != .failedToSync) else { // Show the failed message sheet - showFailedMessageSheet(for: cellViewModel) + showFailedMessageSheet(for: cellViewModel, using: dependencies) return } @@ -864,8 +870,8 @@ extension ConversationVC: let threadId: String = self.viewModel.threadData.threadId // Retry downloading the failed attachment - Storage.shared.writeAsync { db in - JobRunner.add( + dependencies.storage.writeAsync { db in + dependencies.jobRunner.add( db, job: Job( variant: .attachmentDownload, @@ -874,7 +880,9 @@ extension ConversationVC: details: AttachmentDownloadJob.Details( attachmentId: mediaView.attachment.id ) - ) + ), + canStartJob: true, + using: dependencies ) } break @@ -1013,8 +1021,8 @@ extension ConversationVC: self.present(actionSheet, animated: true) } - func handleReplyButtonTapped(for cellViewModel: MessageViewModel) { - reply(cellViewModel) + func handleReplyButtonTapped(for cellViewModel: MessageViewModel, using dependencies: Dependencies) { + reply(cellViewModel, using: dependencies) } func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) { @@ -1123,15 +1131,15 @@ extension ConversationVC: UIView.setAnimationsEnabled(true) } - func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) { - react(cellViewModel, with: emoji.rawValue, remove: false) + func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones, using dependencies: Dependencies) { + react(cellViewModel, with: emoji.rawValue, remove: false, using: dependencies) } - func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones) { - react(cellViewModel, with: emoji.rawValue, remove: true) + func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones, using dependencies: Dependencies) { + react(cellViewModel, with: emoji.rawValue, remove: true, using: dependencies) } - func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { + func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String, using dependencies: Dependencies) { guard cellViewModel.threadVariant == .community else { return } Storage.shared @@ -1208,7 +1216,7 @@ extension ConversationVC: let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() - let recentReactionTimestamps: [Int64] = dependencies.generalCache.recentReactionTimestamps + let recentReactionTimestamps: [Int64] = dependencies.caches[.general].recentReactionTimestamps guard recentReactionTimestamps.count < 20 || @@ -1226,7 +1234,7 @@ extension ConversationVC: return } - dependencies.mutableGeneralCache.mutate { + dependencies.caches.mutate(cache: .general) { $0.recentReactionTimestamps = Array($0.recentReactionTimestamps .suffix(19)) .appending(sentTimestamp) @@ -1261,9 +1269,9 @@ extension ConversationVC: )) } } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .flatMap { pendingChange -> AnyPublisher<(MessageSender.PreparedSendData?, OpenGroupInfo?), Error> in - Storage.shared.writePublisher { [weak self] db -> (MessageSender.PreparedSendData?, OpenGroupInfo?) in + dependencies.storage.writePublisher { [weak self] db -> (MessageSender.PreparedSendData?, OpenGroupInfo?) in // Update the thread to be visible (if it isn't already) if self?.viewModel.threadData.threadShouldBeVisible == false { _ = try SessionThread @@ -1372,7 +1380,8 @@ extension ConversationVC: namespace: try Message.Destination .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant) .defaultNamespace, - interactionId: cellViewModel.id + interactionId: cellViewModel.id, + using: dependencies ) return (sendData, nil) @@ -1382,7 +1391,7 @@ extension ConversationVC: .tryFlatMap { messageSendData, openGroupInfo -> AnyPublisher in switch (messageSendData, openGroupInfo) { case (.some(let sendData), _): - return MessageSender.sendImmediate(preparedSendData: sendData) + return MessageSender.sendImmediate(data: sendData, using: dependencies) case (_, .some(let info)): return OpenGroupAPI.send(data: info.sendData) @@ -1433,14 +1442,14 @@ extension ConversationVC: } } - func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) { + func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { hideInputAccessoryView() let emojiPicker = EmojiPickerSheet( completionHandler: { [weak self] emoji in guard let emoji: EmojiWithSkinTones = emoji else { return } - self?.react(cellViewModel, with: emoji) + self?.react(cellViewModel, with: emoji, using: dependencies) }, dismissHandler: { [weak self] in self?.showInputAccessoryView() @@ -1456,7 +1465,7 @@ extension ConversationVC: // MARK: --action handling - func showFailedMessageSheet(for cellViewModel: MessageViewModel) { + private func showFailedMessageSheet(for cellViewModel: MessageViewModel, using dependencies: Dependencies) { let sheet = UIAlertController( title: (cellViewModel.state == .failedToSync ? "MESSAGE_DELIVERY_FAILED_SYNC_TITLE".localized() : @@ -1483,7 +1492,7 @@ extension ConversationVC: "context_menu_resend".localized() ), style: .default, - handler: { [weak self] _ in self?.retry(cellViewModel) } + handler: { [weak self] _ in self?.retry(cellViewModel, using: dependencies) } )) // HACK: Extracting this info from the error string is pretty dodgy @@ -1596,7 +1605,7 @@ extension ConversationVC: // MARK: - ContextMenuActionDelegate - func info(_ cellViewModel: MessageViewModel) { + func info(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { let mediaInfoVC = MediaInfoVC( attachments: (cellViewModel.attachments ?? []), isOutgoing: (cellViewModel.variant == .standardOutgoing), @@ -1607,8 +1616,8 @@ extension ConversationVC: navigationController?.pushViewController(mediaInfoVC, animated: true) } - func retry(_ cellViewModel: MessageViewModel) { - Storage.shared.writeAsync { [weak self] db in + func retry(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { + dependencies.storage.writeAsync { [weak self] db in guard let threadId: String = self?.viewModel.threadData.threadId, let threadVariant: SessionThread.Variant = self?.viewModel.threadData.threadVariant, @@ -1649,12 +1658,13 @@ extension ConversationVC: interaction: interaction, threadId: threadId, threadVariant: threadVariant, - isSyncMessage: (cellViewModel.state == .failedToSync) + isSyncMessage: (cellViewModel.state == .failedToSync), + using: dependencies ) } } - func reply(_ cellViewModel: MessageViewModel) { + func reply(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( threadId: self.viewModel.threadData.threadId, authorId: cellViewModel.authorId, @@ -1677,7 +1687,7 @@ extension ConversationVC: snInputView.becomeFirstResponder() } - func copy(_ cellViewModel: MessageViewModel) { + func copy(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { switch cellViewModel.cellType { case .typingIndicator, .dateHeader, .unreadMarker: break @@ -1715,7 +1725,7 @@ extension ConversationVC: UIPasteboard.general.string = cellViewModel.authorId } - func delete(_ cellViewModel: MessageViewModel) { + func delete(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { switch cellViewModel.variant { case .standardIncomingDeleted, .infoCall, .infoScreenshotNotification, .infoMediaSavedNotification, @@ -1911,7 +1921,8 @@ extension ConversationVC: message: unsendRequest, threadId: cellViewModel.threadId, interactionId: nil, - to: .contact(publicKey: userPublicKey) + to: .contact(publicKey: userPublicKey), + using: dependencies ) } return @@ -1934,7 +1945,8 @@ extension ConversationVC: message: unsendRequest, threadId: cellViewModel.threadId, interactionId: nil, - to: .contact(publicKey: userPublicKey) + to: .contact(publicKey: userPublicKey), + using: dependencies ) } self?.showInputAccessoryView() @@ -1962,7 +1974,8 @@ extension ConversationVC: message: unsendRequest, interactionId: nil, threadId: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant + threadVariant: cellViewModel.threadVariant, + using: dependencies ) } @@ -1996,7 +2009,7 @@ extension ConversationVC: } } - func save(_ cellViewModel: MessageViewModel) { + func save(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { guard cellViewModel.cellType == .mediaMessage else { return } let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) @@ -2038,24 +2051,10 @@ extension ConversationVC: return } - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - - Storage.shared.writeAsync { db in - try MessageSender.send( - db, - message: DataExtractionNotification( - kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)), - sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) - ), - interactionId: nil, - threadId: threadId, - threadVariant: threadVariant - ) - } + sendDataExtraction(kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs))) } - func ban(_ cellViewModel: MessageViewModel) { + func ban(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId @@ -2111,7 +2110,7 @@ extension ConversationVC: self.present(modal, animated: true) } - func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) { + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) { guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId @@ -2303,23 +2302,29 @@ extension ConversationVC: // MARK: - Data Extraction Notifications - @objc func sendScreenshotNotification() { + @objc func sendScreenshotNotification() { sendDataExtraction(kind: .screenshot) } + + func sendDataExtraction( + kind: DataExtractionNotification.Kind, + using dependencies: Dependencies = Dependencies() + ) { // Only send screenshot notifications to one-to-one conversations guard self.viewModel.threadData.threadVariant == .contact else { return } let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - Storage.shared.writeAsync { db in + dependencies.storage.writeAsync { db in try MessageSender.send( db, message: DataExtractionNotification( - kind: .screenshot, + kind: kind, sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) ), interactionId: nil, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } } @@ -2355,7 +2360,8 @@ extension ConversationVC { for threadId: String, threadVariant: SessionThread.Variant, isNewThread: Bool, - timestampMs: Int64 + timestampMs: Int64, + using dependencies: Dependencies = Dependencies() ) { guard threadVariant == .contact else { return } @@ -2396,7 +2402,8 @@ extension ConversationVC { ), interactionId: nil, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index dde344352..63ccdf71d 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -87,12 +87,18 @@ public class MessageCell: UITableViewCell { protocol MessageCellDelegate: ReactionDelegate { func handleItemLongPressed(_ cellViewModel: MessageViewModel) - func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) + func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer, using dependencies: Dependencies) func handleItemDoubleTapped(_ cellViewModel: MessageViewModel) func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) func openUrl(_ urlString: String) - func handleReplyButtonTapped(for cellViewModel: MessageViewModel) + func handleReplyButtonTapped(for cellViewModel: MessageViewModel, using dependencies: Dependencies) func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool) } + +extension MessageCellDelegate { + func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) { + handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer, using: Dependencies()) + } +} diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 6019d5a4d..3a016fa51 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -861,7 +861,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { isHandlingLongPress = true } - @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { + @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { onTap(gestureRecognizer) } + + private func onTap(_ gestureRecognizer: UITapGestureRecognizer, using dependencies: Dependencies = Dependencies()) { guard let cellViewModel: MessageViewModel = self.viewModel else { return } let location = gestureRecognizer.location(in: self) @@ -897,10 +899,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { if reactionContainerView.convert(reactionView.frame, from: reactionView.superview).contains(convertedLocation) { if reactionView.viewModel.showBorder { - delegate?.removeReact(cellViewModel, for: reactionView.viewModel.emoji) + delegate?.removeReact(cellViewModel, for: reactionView.viewModel.emoji, using: dependencies) } else { - delegate?.react(cellViewModel, with: reactionView.viewModel.emoji) + delegate?.react(cellViewModel, with: reactionView.viewModel.emoji, using: dependencies) } return } @@ -917,7 +919,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } else if snContentView.bounds.contains(snContentView.convert(location, from: self)) { - delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer) + delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer, using: dependencies) } } @@ -985,11 +987,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } - private func reply() { + private func reply(using dependencies: Dependencies = Dependencies()) { guard let cellViewModel: MessageViewModel = self.viewModel else { return } resetReply() - delegate?.handleReplyButtonTapped(for: cellViewModel) + delegate?.handleReplyButtonTapped(for: cellViewModel, using: dependencies) } // MARK: - Convenience diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index 86a5ac164..633dfee2c 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -39,10 +39,10 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel [SectionModel] in - let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey) .fetchOne(db) @@ -156,7 +156,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel () + didTriggerSearch: @escaping () -> (), + using dependencies: Dependencies = Dependencies() ) { self.dependencies = dependencies self.threadId = threadId @@ -196,7 +196,7 @@ class ThreadSettingsViewModel: SessionTableViewModel [SectionModel] in - let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey) .fetchOne(db) @@ -755,7 +755,7 @@ class ThreadSettingsViewModel: SessionTableViewModel AnyPublisher { guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) @@ -599,10 +600,11 @@ class NotificationActionHandler { db, interaction: interaction, threadId: threadId, - threadVariant: thread.variant + threadVariant: thread.variant, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .handleEvents( receiveCompletion: { result in switch result { diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 8f6b3c261..ba1a0a5f2 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -20,7 +20,7 @@ public enum SyncPushTokensJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { // Don't run when inactive or not in main app or if the user doesn't exist yet guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 97607724d..123b2fd33 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -26,7 +26,10 @@ enum Onboarding { return existingPublisher } - private static func createProfileNameRetrievalPublisher(_ requestId: UUID) -> AnyPublisher { + private static func createProfileNameRetrievalPublisher( + _ requestId: UUID, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent guard SessionUtil.userConfigsEnabled else { return Just(nil) @@ -99,7 +102,8 @@ enum Onboarding { ) }(), sentTimestamp: TimeInterval((message.sentTimestamp ?? 0) / 1000), - calledFromConfigHandling: false + calledFromConfigHandling: false, + using: dependencies ) } return () diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index bda18bf9c..8b2a3b548 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -244,7 +244,7 @@ final class NukeDataModal: Modal { UserDefaults.removeAll() // Remove the cached key so it gets re-cached on next access - dependencies.mutableGeneralCache.mutate { + dependencies.caches.mutate(cache: .general) { $0.encodedPublicKey = nil $0.recentReactionTimestamps = [] } diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index df82c5ad7..817d76ee9 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -13,10 +13,7 @@ public final class BackgroundPoller { public static func poll( completionHandler: @escaping (UIBackgroundFetchResult) -> Void, - dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies( - subscribeQueue: .global(qos: .background), - receiveQueue: .main - ) + using dependencies: Dependencies = Dependencies() ) { Publishers .MergeMany( @@ -55,8 +52,8 @@ public final class BackgroundPoller { } ) ) - .subscribe(on: dependencies.subscribeQueue) - .receive(on: dependencies.receiveQueue) + .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) + .receive(on: DispatchQueue.main, using: dependencies) .collect() .sinkUntilComplete( receiveCompletion: { result in @@ -74,7 +71,7 @@ public final class BackgroundPoller { } private static func pollForMessages( - using dependencies: OpenGroupManager.OGMDependencies + using dependencies: Dependencies ) -> AnyPublisher { let userPublicKey: String = getUserHexEncodedPublicKey() @@ -94,7 +91,7 @@ public final class BackgroundPoller { } private static func pollForClosedGroupMessages( - using dependencies: OpenGroupManager.OGMDependencies + using dependencies: Dependencies ) -> [AnyPublisher] { // Fetch all closed groups (excluding any don't contain the current user as a // GroupMemeber as the user is no longer a member of those) diff --git a/SessionMessagingKit/Calls/WebRTCSession.swift b/SessionMessagingKit/Calls/WebRTCSession.swift index 71e098ae2..9db449481 100644 --- a/SessionMessagingKit/Calls/WebRTCSession.swift +++ b/SessionMessagingKit/Calls/WebRTCSession.swift @@ -124,13 +124,14 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { _ db: Database, message: CallMessage, interactionId: Int64?, - in thread: SessionThread + in thread: SessionThread, + using dependencies: Dependencies = Dependencies() ) throws -> AnyPublisher { SNLog("[Calls] Sending pre-offer message.") return MessageSender .sendImmediate( - preparedSendData: try MessageSender + data: try MessageSender .preparedSendData( db, message: message, @@ -138,8 +139,10 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: interactionId - ) + interactionId: interactionId, + using: dependencies + ), + using: dependencies ) .handleEvents(receiveOutput: { _ in SNLog("[Calls] Pre-offer message has been sent.") }) .eraseToAnyPublisher() @@ -147,7 +150,8 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { public func sendOffer( to thread: SessionThread, - isRestartingICEConnection: Bool = false + isRestartingICEConnection: Bool = false, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { SNLog("[Calls] Sending offer message.") let uuid: String = self.uuid @@ -172,7 +176,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } } - Storage.shared + dependencies.storage .writePublisher { db in try MessageSender .preparedSendData( @@ -188,10 +192,11 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete( receiveCompletion: { result in @@ -207,12 +212,15 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { .eraseToAnyPublisher() } - public func sendAnswer(to sessionId: String) -> AnyPublisher { + public func sendAnswer( + to sessionId: String, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { SNLog("[Calls] Sending answer message.") let uuid: String = self.uuid let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) - return Storage.shared + return dependencies.storage .readPublisher { db -> SessionThread in guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { throw WebRTCSessionError.noThread @@ -239,7 +247,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } } - Storage.shared + dependencies.storage .writePublisher { db in try MessageSender .preparedSendData( @@ -254,10 +262,11 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete( receiveCompletion: { result in @@ -283,7 +292,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } } - private func sendICECandidates() { + private func sendICECandidates(using dependencies: Dependencies = Dependencies()) { let candidates: [RTCIceCandidate] = self.queuedICECandidates let uuid: String = self.uuid let contactSessionId: String = self.contactSessionId @@ -291,7 +300,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { // Empty the queue self.queuedICECandidates.removeAll() - Storage.shared + dependencies.storage .writePublisher { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: contactSessionId) else { throw WebRTCSessionError.noThread @@ -315,15 +324,20 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .sinkUntilComplete() } - public func endCall(_ db: Database, with sessionId: String) throws { + public func endCall( + _ db: Database, + with sessionId: String, + using dependencies: Dependencies = Dependencies() + ) throws { guard let thread: SessionThread = try SessionThread.fetchOne(db, id: sessionId) else { return } SNLog("[Calls] Sending end call message.") @@ -340,11 +354,12 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) MessageSender - .sendImmediate(preparedSendData: preparedSendData) + .sendImmediate(data: preparedSendData, using: dependencies) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index bb575a6fa..bedc9fc31 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -1039,7 +1039,10 @@ extension Attachment { } } - internal func upload(to destination: Attachment.Destination) -> AnyPublisher { + internal func upload( + to destination: Attachment.Destination, + using dependencies: Dependencies + ) -> AnyPublisher { // This can occur if an AttachmnetUploadJob was explicitly created for a message // dependant on the attachment being uploaded (in this case the attachment has // already been uploaded so just succeed) diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index 3a3d07498..2500b0d09 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -76,7 +76,7 @@ public extension BlindedIdLookup { openGroupServer: String, openGroupPublicKey: String, isCheckingForOutbox: Bool, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> BlindedIdLookup { var lookup: BlindedIdLookup = (try? BlindedIdLookup .fetchOne(db, id: blindedId)) @@ -94,11 +94,13 @@ public extension BlindedIdLookup { // If we we given a sessionId then validate it is correct and if so save it if let sessionId: String = sessionId, - dependencies.sodium.sessionId( - sessionId, - matchesBlindedId: blindedId, - serverPublicKey: openGroupPublicKey, - genericHash: dependencies.genericHash + dependencies.crypto.verify( + .sessionId( + sessionId, + matchesBlindedId: blindedId, + serverPublicKey: openGroupPublicKey, + using: dependencies + ) ) { lookup = try lookup @@ -115,9 +117,16 @@ public extension BlindedIdLookup { .fetchCursor(db) while let contact: Contact = try contactsThatApprovedMeCursor.next() { - guard dependencies.sodium.sessionId(contact.id, matchesBlindedId: blindedId, serverPublicKey: openGroupPublicKey, genericHash: dependencies.genericHash) else { - continue - } + guard + dependencies.crypto.verify( + .sessionId( + contact.id, + matchesBlindedId: blindedId, + serverPublicKey: openGroupPublicKey, + using: dependencies + ) + ) + else { continue } // We found a match so update the lookup and leave the loop lookup = try lookup @@ -151,11 +160,13 @@ public extension BlindedIdLookup { while let otherLookup: BlindedIdLookup = try blindedIdLookupCursor.next() { guard let sessionId: String = otherLookup.sessionId, - dependencies.sodium.sessionId( - sessionId, - matchesBlindedId: blindedId, - serverPublicKey: openGroupPublicKey, - genericHash: dependencies.genericHash + dependencies.crypto.verify( + .sessionId( + sessionId, + matchesBlindedId: blindedId, + serverPublicKey: openGroupPublicKey, + using: dependencies + ) ) else { continue } diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 6f3b05c47..51f77ab8f 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -55,12 +55,12 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis isBlocked: Bool = false, didApproveMe: Bool = false, hasBeenBlocked: Bool = false, - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { self.id = id self.isTrusted = ( isTrusted || - id == getUserHexEncodedPublicKey(dependencies: dependencies) // Always trust ourselves + id == getUserHexEncodedPublicKey(using: dependencies) // Always trust ourselves ) self.isApproved = isApproved self.isBlocked = isBlocked diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 21b29295b..c50645ca0 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -797,22 +797,19 @@ public extension Interaction { _ db: Database, threadId: String, body: String?, - quoteAuthorId: String? = nil + quoteAuthorId: String? = nil, + using dependencies: Dependencies = Dependencies() ) -> Bool { var publicKeysToCheck: [String] = [ - getUserHexEncodedPublicKey(db) + getUserHexEncodedPublicKey(db, using: dependencies) ] // If the thread is an open group then add the blinded id as a key to check if let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId) { - let sodium: Sodium = Sodium() - if let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), - let blindedKeyPair: KeyPair = sodium.blindedKeyPair( - serverPublicKey: openGroup.publicKey, - edKeyPair: userEd25519KeyPair, - genericHash: sodium.genericHash + let blindedKeyPair: KeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEd25519KeyPair, using: dependencies) ) { publicKeysToCheck.append(SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString) diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 007a6f10c..cc4e4bef6 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -250,7 +250,7 @@ public extension Profile { /// /// **Note:** This method intentionally does **not** save the newly created Profile, /// it will need to be explicitly saved after calling - static func fetchOrCreateCurrentUser(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> Profile { + static func fetchOrCreateCurrentUser(_ db: Database? = nil, using dependencies: Dependencies = Dependencies()) -> Profile { let userPublicKey: String = getUserHexEncodedPublicKey(db) guard let db: Database = db else { diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 958e7fc30..163d38038 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -525,12 +525,19 @@ public extension SessionThread { _ db: Database? = nil, threadId: String, threadVariant: Variant, - blindingPrefix: SessionId.Prefix + blindingPrefix: SessionId.Prefix, + using dependencies: Dependencies = Dependencies() ) -> String? { guard threadVariant == .community else { return nil } guard let db: Database = db else { - return Storage.shared.read { db in - getUserHexEncodedBlindedKey(db, threadId: threadId, threadVariant: threadVariant, blindingPrefix: blindingPrefix) + return dependencies.storage.read { db in + getUserHexEncodedBlindedKey( + db, + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: blindingPrefix, + using: dependencies + ) } } @@ -559,12 +566,8 @@ public extension SessionThread { guard capabilities.isEmpty || capabilities.contains(.blind) else { return nil } - let sodium: Sodium = Sodium() - - let blindedKeyPair: KeyPair? = sodium.blindedKeyPair( - serverPublicKey: openGroupInfo.publicKey, - edKeyPair: userEdKeyPair, - genericHash: sodium.getGenericHash() + let blindedKeyPair: KeyPair? = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroupInfo.publicKey, edKeyPair: userEdKeyPair, using: dependencies) ) return blindedKeyPair.map { keyPair -> String in diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 1b64721d5..3eb523085 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -17,7 +17,7 @@ public enum AttachmentDownloadJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let threadId: String = job.threadId, diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 5ed000623..99a7a6bbd 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -17,7 +17,7 @@ public enum AttachmentUploadJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let threadId: String = job.threadId, @@ -72,7 +72,7 @@ public enum AttachmentUploadJob: JobExecutor { // reentrancy issues when the success/failure closures get called before the upload as the JobRunner // will attempt to update the state of the job immediately attachment - .upload(to: (openGroup.map { .openGroup($0) } ?? .fileServer)) + .upload(to: (openGroup.map { .openGroup($0) } ?? .fileServer), using: dependencies) .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( @@ -95,7 +95,8 @@ public enum AttachmentUploadJob: JobExecutor { message: details.message, with: .other(error), interactionId: interactionId, - isSyncMessage: details.isSyncMessage + isSyncMessage: details.isSyncMessage, + using: dependencies ) } diff --git a/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift index 38a3b2dcb..ee5c00c9c 100644 --- a/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift @@ -15,7 +15,7 @@ public enum ConfigMessageReceiveJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { /// When the `configMessageReceive` job fails we want to unblock any `messageReceive` jobs it was blocking /// to ensure the user isn't losing any messages - this generally _shouldn't_ happen but if it does then having a temporary diff --git a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift index 8c471b4db..f19caf473 100644 --- a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift @@ -18,7 +18,7 @@ public enum ConfigurationSyncJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard SessionUtil.userConfigsEnabled, @@ -43,7 +43,7 @@ public enum ConfigurationSyncJob: JobExecutor { // it again immediately which is pointless) let updatedJob: Job? = dependencies.storage.write { db in try job - .with(nextRunTimestamp: Date().timeIntervalSince1970 + maxRunFrequency) + .with(nextRunTimestamp: dependencies.dateNow.timeIntervalSince1970 + maxRunFrequency) .saved(db) } @@ -79,7 +79,7 @@ public enum ConfigurationSyncJob: JobExecutor { .map { $0.obsoleteHashes } .reduce([], +) .asSet() - let jobStartTimestamp: TimeInterval = Date().timeIntervalSince1970 + let jobStartTimestamp: TimeInterval = dependencies.dateNow.timeIntervalSince1970 SNLog("[ConfigurationSyncJob] For \(publicKey) started with \(pendingConfigChanges.count) change\(pendingConfigChanges.count == 1 ? "" : "s")") dependencies.storage @@ -105,7 +105,8 @@ public enum ConfigurationSyncJob: JobExecutor { return (snodeMessage, namespace) }, - allObsoleteHashes: Array(allObsoleteHashes) + allObsoleteHashes: Array(allObsoleteHashes), + using: dependencies ) } .subscribe(on: queue) @@ -223,7 +224,7 @@ public extension ConfigurationSyncJob { ) ), canStartJob: true, - dependencies: dependencies + using: dependencies ) return } @@ -231,16 +232,16 @@ public extension ConfigurationSyncJob { // Upsert a config sync job if needed dependencies.jobRunner.upsert( db, - job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey, dependencies: dependencies), + job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey, using: dependencies), canStartJob: true, - dependencies: dependencies + using: dependencies ) } @discardableResult static func createIfNeeded( _ db: Database, publicKey: String, - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) -> Job? { /// The ConfigurationSyncJob will automatically reschedule itself to run again after 3 seconds so if there is an existing /// job then there is no need to create another instance @@ -266,7 +267,7 @@ public extension ConfigurationSyncJob { ) } - static func run() -> AnyPublisher { + static func run(using dependencies: Dependencies = Dependencies()) -> AnyPublisher { // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent guard SessionUtil.userConfigsEnabled else { return Storage.shared @@ -276,17 +277,18 @@ public extension ConfigurationSyncJob { // fresh install due to the migrations getting run) guard Identity.userCompletedRequiredOnboarding(db) else { throw StorageError.generic } - let publicKey: String = getUserHexEncodedPublicKey(db) + let publicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) return try MessageSender.preparedSendData( db, message: try ConfigurationMessage.getCurrent(db), to: Message.Destination.contact(publicKey: publicKey), namespace: .default, - interactionId: nil + interactionId: nil, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .eraseToAnyPublisher() } @@ -298,7 +300,8 @@ public extension ConfigurationSyncJob { queue: .global(qos: .userInitiated), success: { _, _, _ in resolver(Result.success(())) }, failure: { _, error, _, _ in resolver(Result.failure(error ?? HTTPError.generic)) }, - deferred: { _, _ in } + deferred: { _, _ in }, + using: dependencies ) } } diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index 6434b9b52..ac0c55a12 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -16,7 +16,7 @@ public enum DisappearingMessagesJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { // The 'backgroundTask' gets captured and cleared within the 'completion' block let timestampNowMs: TimeInterval = TimeInterval(SnodeAPI.currentOffsetTimestampMs()) diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index 44b5e1921..20eac0a93 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -16,7 +16,7 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { var changeCount: Int = -1 diff --git a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index 9ec106631..e458de633 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -15,7 +15,7 @@ public enum FailedMessageSendsJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { var changeCount: Int = -1 var attachmentChangeCount: Int = -1 diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index b35bf8621..7b5989579 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -23,7 +23,7 @@ public enum GarbageCollectionJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { /// Determine what types of data we want to collect (if we didn't provide any then assume we want to collect everything) /// @@ -33,18 +33,18 @@ public enum GarbageCollectionJob: JobExecutor { .map { try? JSONDecoder().decode(Details.self, from: $0) }? .typesToCollect) .defaulting(to: Types.allCases) - let timestampNow: TimeInterval = Date().timeIntervalSince1970 + let timestampNow: TimeInterval = dependencies.dateNow.timeIntervalSince1970 /// Only do a full collection if the job isn't the recurring one or it's been 23 hours since it last ran (23 hours so a user who opens the /// app at about the same time every day will trigger the garbage collection) - since this runs when the app becomes active we /// want to prevent it running to frequently (the app becomes active if a system alert, the notification center or the control panel /// are shown) - let lastGarbageCollection: Date = UserDefaults.standard[.lastGarbageCollection] + let lastGarbageCollection: Date = dependencies.standardUserDefaults[.lastGarbageCollection] .defaulting(to: Date.distantPast) let finalTypesToCollect: Set = { guard job.behaviour != .recurringOnActive || - Date().timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) + dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) else { // Note: This should only contain the `Types` which are unlikely to ever cause // a startup delay (ie. avoid mass deletions and file management) @@ -450,8 +450,8 @@ public enum GarbageCollectionJob: JobExecutor { // If we did a full collection then update the 'lastGarbageCollection' date to // prevent a full collection from running again in the next 23 hours - if job.behaviour == .recurringOnActive && Date().timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) { - UserDefaults.standard[.lastGarbageCollection] = Date() + if job.behaviour == .recurringOnActive && dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) { + dependencies.standardUserDefaults[.lastGarbageCollection] = dependencies.dateNow } success(job, false, dependencies) diff --git a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift index 9eb36cccf..65d2601d6 100644 --- a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift @@ -18,7 +18,7 @@ public enum GroupLeavingJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { guard let detailsData: Data = job.details, @@ -51,10 +51,11 @@ public enum GroupLeavingJob: JobExecutor { to: destination, namespace: destination.defaultNamespace, interactionId: job.interactionId, - isSyncMessage: false + isSyncMessage: false, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 696442bd8..fabae4e97 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -15,7 +15,7 @@ public enum MessageReceiveJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { guard let threadId: String = job.threadId, diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 3ff522544..df411c1e0 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -18,7 +18,7 @@ public enum MessageSendJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let detailsData: Data = job.details, @@ -90,6 +90,10 @@ public enum MessageSendJob: JobExecutor { switch attachment.state { case .uploading, .pendingDownload, .downloading, .failedUpload, .downloaded: return true + + // If we've somehow got an attachment that is in an 'uploaded' state but doesn't + // have a 'downloadUrl' then it's invalid and needs to be re-uploaded + case .uploaded: return (attachment.downloadUrl == nil) default: return false } @@ -122,7 +126,7 @@ public enum MessageSendJob: JobExecutor { ) } .compactMap { attachmentId -> (jobId: Int64, job: Job)? in - JobRunner + dependencies.jobRunner .insert( db, job: Job( @@ -171,13 +175,14 @@ public enum MessageSendJob: JobExecutor { to: details.destination, namespace: details.destination.defaultNamespace, interactionId: job.interactionId, - isSyncMessage: details.isSyncMessage + isSyncMessage: details.isSyncMessage, + using: dependencies ) } .map { sendData in sendData.with(fileIds: messageFileIds) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } - .subscribe(on: queue) - .receive(on: queue) + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } + .subscribe(on: queue, using: dependencies) + .receive(on: queue, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in switch result { diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index a128f459d..01500f714 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -16,7 +16,7 @@ public enum NotifyPushServerJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let detailsData: Data = job.details, diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift index d16e8ce9b..7310f120e 100644 --- a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -15,7 +15,7 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { // Don't run when inactive or not in main app guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 77097df92..8b5090afb 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -17,7 +17,7 @@ public enum SendReadReceiptsJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { guard let threadId: String = job.threadId, @@ -47,7 +47,7 @@ public enum SendReadReceiptsJob: JobExecutor { isSyncMessage: false ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( @@ -59,7 +59,7 @@ public enum SendReadReceiptsJob: JobExecutor { // another one for the same thread but with a 'nextRunTimestamp' set to the // 'maxRunFrequency' value to throttle the read receipt requests var shouldFinishCurrentJob: Bool = false - let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + maxRunFrequency) + let nextRunTimestamp: TimeInterval = (dependencies.dateNow.timeIntervalSince1970 + maxRunFrequency) let updatedJob: Job? = Storage.shared.write { db in // If another 'sendReadReceipts' job was scheduled then update that one diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index c29ff6200..21afcc6de 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -15,7 +15,7 @@ public enum UpdateProfilePictureJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { // Don't run when inactive or not in main app guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { @@ -24,8 +24,8 @@ public enum UpdateProfilePictureJob: JobExecutor { // Only re-upload the profile picture if enough time has passed since the last upload guard - let lastProfilePictureUpload: Date = UserDefaults.standard[.lastProfilePictureUpload], - Date().timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) + let lastProfilePictureUpload: Date = dependencies.standardUserDefaults[.lastProfilePictureUpload], + dependencies.dateNow.timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) else { // Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck // in a loop endlessly deferring the job @@ -42,7 +42,7 @@ public enum UpdateProfilePictureJob: JobExecutor { } // Note: The user defaults flag is updated in ProfileManager - let profile: Profile = Profile.fetchOrCreateCurrentUser(dependencies: dependencies) + let profile: Profile = Profile.fetchOrCreateCurrentUser(using: dependencies) let profilePictureData: Data? = profile.profilePictureFileName .map { ProfileManager.loadProfileData(with: $0) } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 7de4f560e..d9d6e3caa 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -230,7 +230,8 @@ public extension Message { static func processRawReceivedMessage( _ db: Database, - rawMessage: SnodeReceivedMessage + rawMessage: SnodeReceivedMessage, + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { guard let envelope = SNProtoEnvelope.from(rawMessage) else { throw MessageReceiverError.invalidMessage @@ -242,7 +243,8 @@ public extension Message { envelope: envelope, serverExpirationTimestamp: (TimeInterval(rawMessage.info.expirationDateMs) / 1000), serverHash: rawMessage.info.hash, - handleClosedGroupKeyUpdateMessages: true + handleClosedGroupKeyUpdateMessages: true, + using: dependencies ) // Ensure we actually want to de-dupe messages for this namespace, otherwise just @@ -289,7 +291,8 @@ public extension Message { static func processRawReceivedMessage( _ db: Database, serializedData: Data, - serverHash: String? + serverHash: String?, + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { guard let envelope = try? SNProtoEnvelope.parseData(serializedData) else { throw MessageReceiverError.invalidMessage @@ -303,7 +306,8 @@ public extension Message { ControlMessageProcessRecord.defaultExpirationSeconds ), serverHash: serverHash, - handleClosedGroupKeyUpdateMessages: true + handleClosedGroupKeyUpdateMessages: true, + using: dependencies ) } @@ -312,7 +316,8 @@ public extension Message { /// closed group key update messages (the `NotificationServiceExtension` does this itself) static func processRawReceivedMessageAsNotification( _ db: Database, - envelope: SNProtoEnvelope + envelope: SNProtoEnvelope, + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { let processedMessage: ProcessedMessage? = try processRawReceivedMessage( db, @@ -322,7 +327,8 @@ public extension Message { ControlMessageProcessRecord.defaultExpirationSeconds ), serverHash: nil, - handleClosedGroupKeyUpdateMessages: false + handleClosedGroupKeyUpdateMessages: false, + using: dependencies ) return processedMessage @@ -334,7 +340,7 @@ public extension Message { openGroupServerPublicKey: String, message: OpenGroupAPI.Message, data: Data, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { // Need a sender in order to process the message guard let sender: String = message.sender, let timestamp = message.posted else { return nil } @@ -357,7 +363,7 @@ public extension Message { openGroupMessageServerId: message.id, openGroupServerPublicKey: openGroupServerPublicKey, handleClosedGroupKeyUpdateMessages: false, - dependencies: dependencies + using: dependencies ) } @@ -368,7 +374,7 @@ public extension Message { data: Data, isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> ProcessedMessage? { // Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps let envelopeBuilder = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000))) @@ -390,7 +396,7 @@ public extension Message { isOutgoing: isOutgoing, otherBlindedPublicKey: otherBlindedPublicKey, handleClosedGroupKeyUpdateMessages: false, - dependencies: dependencies + using: dependencies ) } @@ -399,24 +405,26 @@ public extension Message { openGroupId: String, message: OpenGroupAPI.Message, associatedPendingChanges: [OpenGroupAPI.PendingChange], - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) -> [Reaction] { var results: [Reaction] = [] guard let reactions = message.reactions else { return results } - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let blinded15UserPublicKey: String? = SessionThread .getUserHexEncodedBlindedKey( db, threadId: openGroupId, threadVariant: .community, - blindingPrefix: .blinded15 + blindingPrefix: .blinded15, + using: dependencies ) let blinded25UserPublicKey: String? = SessionThread .getUserHexEncodedBlindedKey( db, threadId: openGroupId, threadVariant: .community, - blindingPrefix: .blinded25 + blindingPrefix: .blinded25, + using: dependencies ) for (encodedEmoji, rawReaction) in reactions { @@ -536,7 +544,7 @@ public extension Message { isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, handleClosedGroupKeyUpdateMessages: Bool, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> ProcessedMessage? { let (message, proto, threadId, threadVariant) = try MessageReceiver.parse( db, @@ -547,7 +555,7 @@ public extension Message { openGroupServerPublicKey: openGroupServerPublicKey, isOutgoing: isOutgoing, otherBlindedPublicKey: otherBlindedPublicKey, - dependencies: dependencies + using: dependencies ) message.serverHash = serverHash @@ -568,7 +576,8 @@ public extension Message { db, threadId: threadId, threadVariant: threadVariant, - message: closedGroupControlMessage + message: closedGroupControlMessage, + using: dependencies ) return nil diff --git a/SessionMessagingKit/Open Groups/Crypto/OpenGroupAPI+Crypto.swift b/SessionMessagingKit/Open Groups/Crypto/OpenGroupAPI+Crypto.swift new file mode 100644 index 000000000..b4cdb41c5 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Crypto/OpenGroupAPI+Crypto.swift @@ -0,0 +1,403 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import CryptoKit +import Sodium +import Clibsodium +import Curve25519Kit +import SessionUtilitiesKit + +// MARK: - Nonce + +internal extension OpenGroupAPI { + class NonceGenerator16Byte: NonceGenerator { + public var NonceBytes: Int { 16 } + } + + class NonceGenerator24Byte: NonceGenerator { + public var NonceBytes: Int { 24 } + } +} + +public extension Crypto.Size { + static let nonce16: Crypto.Size = Crypto.Size(id: "nonce16") { OpenGroupAPI.NonceGenerator16Byte().NonceBytes } + static let nonce24: Crypto.Size = Crypto.Size(id: "nonce24") { OpenGroupAPI.NonceGenerator24Byte().NonceBytes } +} + +public extension Crypto.Action { + static func generateNonce16() -> Crypto.Action { + return Crypto.Action(id: "generateNonce16") { OpenGroupAPI.NonceGenerator16Byte().nonce() } + } + + static func generateNonce24() -> Crypto.Action { + return Crypto.Action(id: "generateNonce24") { OpenGroupAPI.NonceGenerator24Byte().nonce() } + } +} + +// MARK: - AeadXChaCha20Poly1305Ietf + +public extension Crypto.Size { + static let aeadXChaCha20KeyBytes: Crypto.Size = Crypto.Size(id: "aeadXChaCha20KeyBytes") { + Sodium().aead.xchacha20poly1305ietf.KeyBytes + } + static let aeadXChaCha20ABytes: Crypto.Size = Crypto.Size(id: "aeadXChaCha20ABytes") { + Sodium().aead.xchacha20poly1305ietf.ABytes + } +} + +public extension Crypto.Action { + /// This method is the same as the standard AeadXChaCha20Poly1305Ietf `encrypt` method except it allows the + /// specification of a nonce which allows for deterministic behaviour with unit testing + static func encryptAeadXChaCha20( + message: Bytes, + secretKey: Bytes, + nonce: Bytes, + additionalData: Bytes? = nil, + using dependencies: Dependencies + ) -> Crypto.Action { + return Crypto.Action( + id: "encryptAeadXChaCha20", + args: [message, secretKey, nonce, additionalData] + ) { + guard secretKey.count == dependencies.crypto.size(.aeadXChaCha20KeyBytes) else { return nil } + + var authenticatedCipherText = Bytes( + repeating: 0, + count: message.count + dependencies.crypto.size(.aeadXChaCha20ABytes) + ) + var authenticatedCipherTextLen: UInt64 = 0 + + let result = crypto_aead_xchacha20poly1305_ietf_encrypt( + &authenticatedCipherText, &authenticatedCipherTextLen, + message, UInt64(message.count), + additionalData, UInt64(additionalData?.count ?? 0), + nil, nonce, secretKey + ) + + guard result == 0 else { return nil } + + return authenticatedCipherText + } + } + + static func decryptAeadXChaCha20( + authenticatedCipherText: Bytes, + secretKey: Bytes, + nonce: Bytes, + additionalData: Bytes? = nil + ) -> Crypto.Action { + return Crypto.Action( + id: "decryptAeadXChaCha20", + args: [authenticatedCipherText, secretKey, nonce, additionalData] + ) { + return Sodium().aead.xchacha20poly1305ietf.decrypt( + authenticatedCipherText: authenticatedCipherText, + secretKey: secretKey, + nonce: nonce, + additionalData: additionalData + ) + } + } +} + +// MARK: - Blinding + +/// These extenion methods are used to generate a sign "blinded" messages +/// +/// According to the Swift engineers the only situation when `UnsafeRawBufferPointer.baseAddress` is nil is when it's an +/// empty collection; as such our guard cases wihch return `-1` when unwrapping this value should never be hit and we can ignore +/// them as possible results. +/// +/// For more information see: +/// https://forums.swift.org/t/when-is-unsafemutablebufferpointer-baseaddress-nil/32136/5 +/// https://github.com/apple/swift-evolution/blob/master/proposals/0055-optional-unsafe-pointers.md#unsafebufferpointer +public extension Crypto.Action { + private static let scalarLength: Int = Int(crypto_core_ed25519_scalarbytes()) // 32 + private static let noClampLength: Int = Int(Sodium.lib_crypto_scalarmult_ed25519_bytes()) // 32 + private static let scalarMultLength: Int = Int(crypto_scalarmult_bytes()) // 32 + fileprivate static let publicKeyLength: Int = Int(crypto_scalarmult_bytes()) // 32 + fileprivate static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64 + + /// 64-byte blake2b hash then reduce to get the blinding factor + static func generateBlindingFactor( + serverPublicKey: String, + using dependencies: Dependencies + ) -> Crypto.Action { + return Crypto.Action( + id: "generateBlindingFactor", + args: [serverPublicKey] + ) { + /// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) + let serverPubKeyData: Data = Data(hex: serverPublicKey) + + guard + !serverPubKeyData.isEmpty, + let serverPublicKeyHashBytes: Bytes = try? dependencies.crypto.perform( + .hash(message: [UInt8](serverPubKeyData), outputLength: 64) + ) + else { return nil } + + /// Reduce the server public key into an ed25519 scalar (`k`) + let kPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + + _ = serverPublicKeyHashBytes.withUnsafeBytes { (serverPublicKeyHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let serverPublicKeyHashBaseAddress: UnsafePointer = serverPublicKeyHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_reduce(kPtr, serverPublicKeyHashBaseAddress) + return 0 + } + + return Data(bytes: kPtr, count: Crypto.Action.scalarLength).bytes + } + } + + /// Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to + /// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the + /// same secret scalar secret (and so this is just the most convenient way to get 'a' out of + /// a sodium Ed25519 secret key) + fileprivate static func generatePrivateKeyScalar(secretKey: Bytes) -> Bytes { + /// a = s.to_curve25519_private_key().encode() + let aPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarMultLength) + + /// Looks like the `crypto_sign_ed25519_sk_to_curve25519` function can't actually fail so no need to verify the result + /// See: https://github.com/jedisct1/libsodium/blob/master/src/libsodium/crypto_sign/ed25519/ref10/keypair.c#L70 + _ = secretKey.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in + guard let secretKeyBaseAddress: UnsafePointer = secretKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + return crypto_sign_ed25519_sk_to_curve25519(aPtr, secretKeyBaseAddress) + } + + return Data(bytes: aPtr, count: Crypto.Action.scalarMultLength).bytes + } + + /// Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the + /// construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded + /// pubkeys (this doesn't affect verification at all) + static func sogsSignature( + message: Bytes, + secretKey: Bytes, + blindedSecretKey ka: Bytes, + blindedPublicKey kA: Bytes + ) -> Crypto.Action { + return Crypto.Action( + id: "sogsSignature", + args: [message, secretKey, ka, kA] + ) { + /// H_rh = sha512(s.encode()).digest()[32:] + let H_rh: Bytes = Bytes(SHA512.hash(data: secretKey).suffix(32)) + + /// r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts)) + let combinedHashBytes: Bytes = SHA512.hash(data: H_rh + kA + message).bytes + let rPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + + _ = combinedHashBytes.withUnsafeBytes { (combinedHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let combinedHashBaseAddress: UnsafePointer = combinedHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_reduce(rPtr, combinedHashBaseAddress) + return 0 + } + + /// sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) + let sig_RPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.noClampLength) + guard crypto_scalarmult_ed25519_base_noclamp(sig_RPtr, rPtr) == 0 else { return nil } + + /// HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts)) + let sig_RBytes: Bytes = Data(bytes: sig_RPtr, count: Crypto.Action.noClampLength).bytes + let HRAMHashBytes: Bytes = SHA512.hash(data: sig_RBytes + kA + message).bytes + let HRAMPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + + _ = HRAMHashBytes.withUnsafeBytes { (HRAMHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let HRAMHashBaseAddress: UnsafePointer = HRAMHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_reduce(HRAMPtr, HRAMHashBaseAddress) + return 0 + } + + /// sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) + let sig_sMulPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + let sig_sPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.scalarLength) + + _ = ka.withUnsafeBytes { (kaPtr: UnsafeRawBufferPointer) -> Int32 in + guard let kaBaseAddress: UnsafePointer = kaPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_mul(sig_sMulPtr, HRAMPtr, kaBaseAddress) + Sodium.lib_crypto_core_ed25519_scalar_add(sig_sPtr, rPtr, sig_sMulPtr) + return 0 + } + + /// full_sig = sig_R + sig_s + return (Data(bytes: sig_RPtr, count: Crypto.Action.noClampLength).bytes + Data(bytes: sig_sPtr, count: Crypto.Action.scalarLength).bytes) + } + } + + /// Combines two keys (`kA`) + static func combineKeys( + lhsKeyBytes: Bytes, + rhsKeyBytes: Bytes + ) -> Crypto.Action { + return Crypto.Action( + id: "combineKeys", + args: [lhsKeyBytes, rhsKeyBytes] + ) { + let combinedPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.noClampLength) + + let result = rhsKeyBytes.withUnsafeBytes { (rhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in + return lhsKeyBytes.withUnsafeBytes { (lhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in + guard let lhsKeyBytesBaseAddress: UnsafePointer = lhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + guard let rhsKeyBytesBaseAddress: UnsafePointer = rhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + return Sodium.lib_crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress) + } + } + + /// Ensure the above worked + guard result == 0 else { return nil } + + return Data(bytes: combinedPtr, count: Crypto.Action.noClampLength).bytes + } + } + + /// Calculate a shared secret for a message from A to B: + /// + /// BLAKE2b(a kB || kA || kB) + /// + /// The receiver can calulate the same value via: + /// + /// BLAKE2b(b kA || kA || kB) + static func sharedBlindedEncryptionKey( + secretKey: Bytes, + otherBlindedPublicKey: Bytes, + fromBlindedPublicKey kA: Bytes, + toBlindedPublicKey kB: Bytes, + using dependencies: Dependencies + ) -> Crypto.Action { + return Crypto.Action( + id: "sharedBlindedEncryptionKey", + args: [secretKey, otherBlindedPublicKey, kA, kB] + ) { + let aBytes: Bytes = generatePrivateKeyScalar(secretKey: secretKey) + let combinedKeyBytes: Bytes = try dependencies.crypto.perform( + .combineKeys(lhsKeyBytes: aBytes, rhsKeyBytes: otherBlindedPublicKey) + ) + + return try dependencies.crypto.perform( + .hash(message: (combinedKeyBytes + kA + kB), outputLength: 32) + ) + } + } +} + +public extension Crypto.KeyPairType { + /// Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` + static func blindedKeyPair( + serverPublicKey: String, + edKeyPair: KeyPair, + using dependencies: Dependencies + ) -> Crypto.KeyPairType { + return Crypto.KeyPairType( + id: "blindedKeyPair", + args: [serverPublicKey, edKeyPair] + ) { + guard + edKeyPair.publicKey.count == Crypto.Action.publicKeyLength, + edKeyPair.secretKey.count == Crypto.Action.secretKeyLength, + let kBytes: Bytes = try? dependencies.crypto.perform( + .generateBlindingFactor(serverPublicKey: serverPublicKey, using: dependencies) + ) + else { return nil } + + let aBytes: Bytes = Crypto.Action.generatePrivateKeyScalar(secretKey: edKeyPair.secretKey) + + /// Generate the blinded key pair `ka`, `kA` + let kaPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.secretKeyLength) + let kAPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Crypto.Action.publicKeyLength) + + _ = aBytes.withUnsafeBytes { (aPtr: UnsafeRawBufferPointer) -> Int32 in + return kBytes.withUnsafeBytes { (kPtr: UnsafeRawBufferPointer) -> Int32 in + guard let kBaseAddress: UnsafePointer = kPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + guard let aBaseAddress: UnsafePointer = aPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress) + return 0 + } + } + + guard crypto_scalarmult_ed25519_base_noclamp(kAPtr, kaPtr) == 0 else { return nil } + + return KeyPair( + publicKey: Data(bytes: kAPtr, count: Crypto.Action.publicKeyLength).bytes, + secretKey: Data(bytes: kaPtr, count: Crypto.Action.secretKeyLength).bytes + ) + } + } +} + +public extension Crypto.Verification { + /// This method should be used to check if a users standard sessionId matches a blinded one + static func sessionId( + _ standardSessionId: String, + matchesBlindedId blindedSessionId: String, + serverPublicKey: String, + using dependencies: Dependencies + ) -> Crypto.Verification { + return Crypto.Verification( + id: "sessionId", + args: [standardSessionId, blindedSessionId, serverPublicKey] + ) { + // Only support generating blinded keys for standard session ids + guard + let sessionId: SessionId = SessionId(from: standardSessionId), + sessionId.prefix == .standard, + let blindedId: SessionId = SessionId(from: blindedSessionId), + ( + blindedId.prefix == .blinded15 || + blindedId.prefix == .blinded25 + ), + let kBytes: Bytes = try? dependencies.crypto.perform( + .generateBlindingFactor(serverPublicKey: serverPublicKey, using: dependencies) + ) + else { return false } + + /// From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is the positive (which is what + /// Signal's XEd25519 conversion always uses) + /// + /// Note: The below method is code we have exposed from the `curve25519_verify` method within the Curve25519 library + /// rather than custom code we have written + guard let xEd25519Key: Data = try? Ed25519.publicKey(from: Data(hex: sessionId.publicKey)) else { return false } + + /// Blind the positive public key + guard + let pk1: Bytes = try? dependencies.crypto.perform( + .combineKeys(lhsKeyBytes: kBytes, rhsKeyBytes: xEd25519Key.bytes) + ) + else { return false } + + /// For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2 + /// pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000]) + let pk2: Bytes = (pk1[0..<31] + [(pk1[31] ^ 0b1000_0000)]) + + return ( + SessionId(.blinded15, publicKey: pk1).publicKey == blindedId.publicKey || + SessionId(.blinded15, publicKey: pk2).publicKey == blindedId.publicKey + ) + } + } +} diff --git a/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift b/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift index efe990e89..073f63816 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift @@ -69,7 +69,6 @@ public extension OpenGroupAPI { info = HTTP.ResponseInfo(code: 0, headers: [:]) data = [:] #endif - } } } diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index b266c26e1..7adecefea 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -69,7 +69,7 @@ extension OpenGroupAPI.Message { guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { throw HTTPError.parsingFailed } - guard let dependencies: SMKDependencies = decoder.userInfo[Dependencies.userInfoKey] as? SMKDependencies else { + guard let dependencies: Dependencies = decoder.userInfo[Dependencies.userInfoKey] as? Dependencies else { throw HTTPError.parsingFailed } @@ -78,13 +78,21 @@ extension OpenGroupAPI.Message { switch SessionId.Prefix(from: sender) { case .blinded15, .blinded25: - guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else { + guard + dependencies.crypto.verify( + .signature(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) + ) + else { SNLog("Ignoring message with invalid signature.") throw HTTPError.parsingFailed } case .standard, .unblinded: - guard (try? dependencies.ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true else { + guard + dependencies.crypto.verify( + .signatureEd25519(signature, publicKey: publicKey, data: data) + ) + else { SNLog("Ignoring message with invalid signature.") throw HTTPError.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 3797dd755..4cd74f821 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -31,7 +31,7 @@ public enum OpenGroupAPI { server: String, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { let lastInboxMessageId: Int64 = (try? OpenGroup .select(.inboxLatestMessageId) @@ -143,7 +143,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, requests: [ErasedPreparedSendData], - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -173,7 +173,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, requests: [ErasedPreparedSendData], - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -202,7 +202,7 @@ public enum OpenGroupAPI { _ db: Database, server: String, forceBlinded: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -225,7 +225,7 @@ public enum OpenGroupAPI { public static func preparedRooms( _ db: Database, server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[Room]> { return try OpenGroupAPI .prepareSendData( @@ -244,7 +244,7 @@ public enum OpenGroupAPI { _ db: Database, for roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -267,7 +267,7 @@ public enum OpenGroupAPI { lastUpdated: Int64, for roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -292,7 +292,7 @@ public enum OpenGroupAPI { _ db: Database, for roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .preparedSequence( @@ -332,13 +332,18 @@ public enum OpenGroupAPI { } } + public typealias CapabilitiesAndRoomsResponse = ( + capabilities: (info: ResponseInfoType, data: Capabilities), + rooms: (info: ResponseInfoType, data: [Room]) + ) + /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those /// methods for the documented behaviour of each method public static func preparedCapabilitiesAndRooms( _ db: Database, on server: String, - using dependencies: SMKDependencies = SMKDependencies() - ) throws -> PreparedSendData<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room]))> { + using dependencies: Dependencies = Dependencies() + ) throws -> PreparedSendData { return try OpenGroupAPI .preparedSequence( db, @@ -351,7 +356,7 @@ public enum OpenGroupAPI { ], using: dependencies ) - .map { (info: ResponseInfoType, response: BatchResponse) -> (capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])) in + .map { (info: ResponseInfoType, response: BatchResponse) -> CapabilitiesAndRoomsResponse in let maybeCapabilities: HTTP.BatchSubResponse? = (response[.capabilities] as? HTTP.BatchSubResponse) let maybeRooms: HTTP.BatchSubResponse<[Room]>? = response.data .first(where: { key, _ in @@ -387,7 +392,7 @@ public enum OpenGroupAPI { whisperTo: String?, whisperMods: Bool, fileIds: [String]?, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { throw OpenGroupAPIError.signingFailed @@ -419,7 +424,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -443,7 +448,7 @@ public enum OpenGroupAPI { fileIds: [Int64]?, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { throw OpenGroupAPIError.signingFailed @@ -473,7 +478,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -497,7 +502,7 @@ public enum OpenGroupAPI { _ db: Database, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[Failable]> { return try OpenGroupAPI .prepareSendData( @@ -526,7 +531,7 @@ public enum OpenGroupAPI { messageId: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[Failable]> { return try OpenGroupAPI .prepareSendData( @@ -555,7 +560,7 @@ public enum OpenGroupAPI { seqNo: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[Failable]> { return try OpenGroupAPI .prepareSendData( @@ -591,7 +596,7 @@ public enum OpenGroupAPI { sessionId: String, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -615,7 +620,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path @@ -646,7 +651,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path @@ -675,7 +680,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path @@ -705,7 +710,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path @@ -743,7 +748,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -766,7 +771,7 @@ public enum OpenGroupAPI { id: Int64, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -788,7 +793,7 @@ public enum OpenGroupAPI { _ db: Database, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -817,7 +822,7 @@ public enum OpenGroupAPI { fileName: String? = nil, to roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -849,7 +854,7 @@ public enum OpenGroupAPI { fileId: String, from roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -872,7 +877,7 @@ public enum OpenGroupAPI { public static func preparedInbox( _ db: Database, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( @@ -893,7 +898,7 @@ public enum OpenGroupAPI { _ db: Database, id: Int64, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( @@ -915,7 +920,7 @@ public enum OpenGroupAPI { ciphertext: Data, toInboxFor blindedSessionId: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -939,7 +944,7 @@ public enum OpenGroupAPI { public static func preparedOutbox( _ db: Database, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( @@ -960,7 +965,7 @@ public enum OpenGroupAPI { _ db: Database, id: Int64, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( @@ -1013,7 +1018,7 @@ public enum OpenGroupAPI { for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -1062,7 +1067,7 @@ public enum OpenGroupAPI { sessionId: String, from roomTokens: [String]?, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( @@ -1140,7 +1145,7 @@ public enum OpenGroupAPI { visible: Bool, for roomTokens: [String]?, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { throw HTTPError.generic @@ -1173,7 +1178,7 @@ public enum OpenGroupAPI { sessionId: String, in roomToken: String, on server: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .preparedSequence( @@ -1208,7 +1213,7 @@ public enum OpenGroupAPI { for serverName: String, fallbackSigningType signingType: SessionId.Prefix, forceBlinded: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> (publicKey: String, signature: Bytes)? { guard let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), @@ -1228,13 +1233,14 @@ public enum OpenGroupAPI { // If we have no capabilities or if the server supports blinded keys then sign using the blinded key if forceBlinded || capabilities.isEmpty || capabilities.contains(.blind) { - guard let blindedKeyPair: KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { - return nil - } - - guard let signatureResult: Bytes = dependencies.sodium.sogsSignature(message: messageBytes, secretKey: userEdKeyPair.secretKey, blindedSecretKey: blindedKeyPair.secretKey, blindedPublicKey: blindedKeyPair.publicKey) else { - return nil - } + guard + let blindedKeyPair: KeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, using: dependencies) + ), + let signatureResult: Bytes = try? dependencies.crypto.perform( + .sogsSignature(message: messageBytes, secretKey: userEdKeyPair.secretKey, blindedSecretKey: blindedKeyPair.secretKey, blindedPublicKey: blindedKeyPair.publicKey) + ) + else { return nil } return ( publicKey: SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString, @@ -1245,9 +1251,11 @@ public enum OpenGroupAPI { // Otherwise sign using the fallback type switch signingType { case .unblinded: - guard let signatureResult: Bytes = dependencies.sign.signature(message: messageBytes, secretKey: userEdKeyPair.secretKey) else { - return nil - } + guard + let signatureResult: Bytes = try? dependencies.crypto.perform( + .signature(message: messageBytes, secretKey: userEdKeyPair.secretKey) + ) + else { return nil } return ( publicKey: SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, @@ -1256,10 +1264,12 @@ public enum OpenGroupAPI { // Default to using the 'standard' key default: - guard let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db) else { return nil } - guard let signatureResult: Bytes = try? dependencies.ed25519.sign(data: messageBytes, keyPair: userKeyPair) else { - return nil - } + guard + let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db), + let signatureResult: Bytes = try? dependencies.crypto.perform( + .signEd25519(data: messageBytes, keyPair: userKeyPair) + ) + else { return nil } return ( publicKey: SessionId(.standard, publicKey: userKeyPair.publicKey).hexString, @@ -1275,7 +1285,7 @@ public enum OpenGroupAPI { for serverName: String, with serverPublicKey: String, forceBlinded: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) -> URLRequest? { guard let url: URL = request.url else { return nil } @@ -1283,12 +1293,12 @@ public enum OpenGroupAPI { let path: String = url.path .appending(url.query.map { value in "?\(value)" }) let method: String = (request.httpMethod ?? "GET") - let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970)) - let nonce: Data = Data(dependencies.nonceGenerator16.nonce()) + let timestamp: Int = Int(floor(dependencies.dateNow.timeIntervalSince1970)) let serverPublicKeyData: Data = Data(hex: serverPublicKey) guard !serverPublicKeyData.isEmpty, + let nonce: Data = (try? dependencies.crypto.perform(.generateNonce16())).map({ Data($0) }), let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes else { return nil } @@ -1296,7 +1306,7 @@ public enum OpenGroupAPI { let bodyHash: Bytes? = { guard let body: Data = request.httpBody else { return nil } - return dependencies.genericHash.hash(message: body.bytes, outputLength: 64) + return try? dependencies.crypto.perform(.hash(message: body.bytes, outputLength: 64)) }() /// Generate the signature message @@ -1341,7 +1351,7 @@ public enum OpenGroupAPI { responseType: R.Type, forceBlinded: Bool = false, timeout: TimeInterval = HTTP.defaultTimeout, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { let urlRequest: URLRequest = try request.generateUrlRequest() let maybePublicKey: String? = try? OpenGroup @@ -1369,19 +1379,21 @@ public enum OpenGroupAPI { /// This method takes in the `PreparedSendData` and actually sends it to the API public static func send( data: PreparedSendData?, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<(ResponseInfoType, R), Error> { guard let validData: PreparedSendData = data else { return Fail(error: OpenGroupAPIError.invalidPreparedData) .eraseToAnyPublisher() } - return dependencies.onionApi - .sendOnionRequest( - validData.request, - to: validData.server, - with: validData.publicKey, - timeout: validData.timeout + return dependencies.network + .send( + .onionRequest( + validData.request, + to: validData.server, + with: validData.publicKey, + timeout: validData.timeout + ) ) .decoded(with: validData, using: dependencies) .eraseToAnyPublisher() diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 72d3e023b..25b6c93df 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -12,48 +12,17 @@ import SessionSnodeKit public final class OpenGroupManager { public typealias DefaultRoomInfo = (room: OpenGroupAPI.Room, existingImageData: Data?) - // MARK: - Cache - - public class Cache: OGMMutableCacheType { - public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error>? - public var groupImagePublishers: [String: AnyPublisher] = [:] - - public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server - public var isPolling: Bool = false - - /// Server URL to value - public var hasPerformedInitialPoll: [String: Bool] = [:] - public var timeSinceLastPoll: [String: TimeInterval] = [:] - - fileprivate var _timeSinceLastOpen: TimeInterval? - public func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval { - if let storedTimeSinceLastOpen: TimeInterval = _timeSinceLastOpen { - return storedTimeSinceLastOpen - } - - guard let lastOpen: Date = dependencies.standardUserDefaults[.lastOpen] else { - _timeSinceLastOpen = .greatestFiniteMagnitude - return .greatestFiniteMagnitude - } - - _timeSinceLastOpen = dependencies.date.timeIntervalSince(lastOpen) - return dependencies.date.timeIntervalSince(lastOpen) - } - - public var pendingChanges: [OpenGroupAPI.PendingChange] = [] - } - // MARK: - Variables public static let shared: OpenGroupManager = OpenGroupManager() // MARK: - Polling - public func startPolling(using dependencies: OGMDependencies = OGMDependencies()) { + public func startPolling(using dependencies: Dependencies = Dependencies()) { // Run on the 'workQueue' to ensure any 'Atomic' access doesn't block the main thread // on startup - OpenGroupAPI.workQueue.async { - guard !dependencies.cache.isPolling else { return } + OpenGroupAPI.workQueue.async(using: dependencies) { + guard !dependencies.caches[.openGroupManager].isPolling else { return } let servers: Set = dependencies.storage .read { db in @@ -70,7 +39,7 @@ public final class OpenGroupManager { .defaulting(to: []) // Update the cache state and re-create all of the pollers - dependencies.mutableCache.mutate { cache in + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.isPolling = true cache.pollers = servers .reduce(into: [:]) { result, server in @@ -80,13 +49,14 @@ public final class OpenGroupManager { } // Now that the pollers have been created actually start them - dependencies.cache.pollers.forEach { _, poller in poller.startIfNeeded(using: dependencies) } + dependencies.caches[.openGroupManager].pollers + .forEach { _, poller in poller.startIfNeeded(using: dependencies) } } } - public func stopPolling(using dependencies: OGMDependencies = OGMDependencies()) { - dependencies.mutableCache.mutate { - $0.pollers.forEach { (_, openGroupPoller) in openGroupPoller.stop() } + public func stopPolling(using dependencies: Dependencies = Dependencies()) { + dependencies.caches.mutate(cache: .openGroupManager) { + $0.pollers.forEach { _, openGroupPoller in openGroupPoller.stop() } $0.pollers.removeAll() $0.isPolling = false } @@ -132,7 +102,13 @@ public final class OpenGroupManager { return options.contains(serverHost) } - public func hasExistingOpenGroup(_ db: Database, roomToken: String, server: String, publicKey: String, dependencies: OGMDependencies = OGMDependencies()) -> Bool { + public func hasExistingOpenGroup( + _ db: Database, + roomToken: String, + server: String, + publicKey: String, + using dependencies: Dependencies = Dependencies() + ) -> Bool { guard let serverUrl: URL = URL(string: server.lowercased()) else { return false } let serverPort: String = OpenGroupManager.port(for: server, serverUrl: serverUrl) @@ -164,7 +140,7 @@ public final class OpenGroupManager { } // First check if there is no poller for the specified server - if Set(dependencies.cache.pollers.keys).intersection(serverOptions).isEmpty { + if Set(dependencies.caches[.openGroupManager].pollers.keys).intersection(serverOptions).isEmpty { return false } @@ -187,10 +163,10 @@ public final class OpenGroupManager { server: String, publicKey: String, calledFromConfigHandling: Bool, - dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> Bool { // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing - if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, dependencies: dependencies) { + if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey, using: dependencies) { SNLog("Ignoring join open group attempt (already joined), user initiated: \(!calledFromConfigHandling)") return false } @@ -256,7 +232,7 @@ public final class OpenGroupManager { server: String, publicKey: String, calledFromConfigHandling: Bool, - dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard successfullyAddedGroup else { return Just(()) @@ -311,7 +287,7 @@ public final class OpenGroupManager { publicKey: publicKey, for: roomToken, on: targetServer, - dependencies: dependencies + using: dependencies ) { resolver(Result.success(())) } @@ -322,7 +298,7 @@ public final class OpenGroupManager { receiveCompletion: { result in switch result { case .finished: break - case .failure: SNLog("Failed to join open group.") + case .failure(let error): SNLog("Failed to join open group with error: \(error).") } } ) @@ -333,7 +309,7 @@ public final class OpenGroupManager { _ db: Database, openGroupId: String, calledFromConfigHandling: Bool, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) { let server: String? = try? OpenGroup .select(.server) @@ -358,9 +334,9 @@ public final class OpenGroupManager { .defaulting(to: 1) if numActiveRooms == 1, let server: String = server?.lowercased() { - let poller = dependencies.cache.pollers[server] + let poller = dependencies.caches[.openGroupManager].pollers[server] poller?.stop() - dependencies.mutableCache.mutate { $0.pollers[server] = nil } + dependencies.caches.mutate(cache: .openGroupManager) { $0.pollers[server] = nil } } // Remove all the data (everything should cascade delete) @@ -435,7 +411,7 @@ public final class OpenGroupManager { for roomToken: String, on server: String, waitForImageToComplete: Bool = false, - dependencies: OGMDependencies = OGMDependencies(), + using dependencies: Dependencies, completion: (() -> ())? = nil ) throws { // Create the open group model and get or create the thread @@ -521,18 +497,19 @@ public final class OpenGroupManager { } } - db.afterNextTransactionNested { _ in + db.afterNextTransactionNested { reentrantDb in // Dispatch async to the workQueue to prevent holding up the DBWrite thread from the // above transaction - OpenGroupAPI.workQueue.async { + OpenGroupAPI.workQueue.async(using: dependencies) { // Start the poller if needed - if dependencies.cache.pollers[server.lowercased()] == nil { - dependencies.mutableCache.mutate { + if dependencies.caches[.openGroupManager].pollers[server.lowercased()] == nil { + dependencies.caches.mutate(cache: .openGroupManager) { $0.pollers[server.lowercased()]?.stop() $0.pollers[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased()) } - dependencies.cache.pollers[server.lowercased()]?.startIfNeeded(using: dependencies) + dependencies.caches[.openGroupManager].pollers[server.lowercased()]? + .startIfNeeded(using: dependencies) } /// Start downloading the room image (if we don't have one or it's been updated) @@ -553,8 +530,8 @@ public final class OpenGroupManager { ) // Note: We need to subscribe and receive on different threads to ensure the // logic in 'receiveValue' doesn't result in a reentrancy database issue - .subscribe(on: OpenGroupAPI.workQueue) - .receive(on: DispatchQueue.global(qos: .default)) + .subscribe(on: OpenGroupAPI.workQueue, using: dependencies) + .receive(on: DispatchQueue.global(qos: .default), using: dependencies) .sinkUntilComplete( receiveCompletion: { _ in if waitForImageToComplete { @@ -562,7 +539,7 @@ public final class OpenGroupManager { } }, receiveValue: { data in - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in _ = try OpenGroup .filter(id: threadId) .updateAll(db, OpenGroup.Columns.imageData.set(to: data)) @@ -588,7 +565,7 @@ public final class OpenGroupManager { messages: [OpenGroupAPI.Message], for roomToken: String, on server: String, - dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies ) { guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { SNLog("Couldn't handle open group messages.") @@ -623,7 +600,7 @@ public final class OpenGroupManager { openGroupServerPublicKey: openGroup.publicKey, message: message, data: data, - dependencies: dependencies + using: dependencies ) if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo { @@ -634,7 +611,7 @@ public final class OpenGroupManager { message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - dependencies: dependencies + using: dependencies ) largestValidSeqNo = max(largestValidSeqNo, message.seqNo) } @@ -661,7 +638,7 @@ public final class OpenGroupManager { db, openGroupId: openGroup.id, message: message, - associatedPendingChanges: dependencies.cache.pendingChanges + associatedPendingChanges: dependencies.caches[.openGroupManager].pendingChanges .filter { guard $0.server == server && $0.room == roomToken && $0.changeType == .reaction else { return false @@ -672,7 +649,7 @@ public final class OpenGroupManager { } return false }, - dependencies: dependencies + using: dependencies ) try MessageReceiver.handleOpenGroupReactions( @@ -708,7 +685,7 @@ public final class OpenGroupManager { .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo)) // Update pendingChange cache based on the `largestValidSeqNo` value - dependencies.mutableCache.mutate { + dependencies.caches.mutate(cache: .openGroupManager) { $0.pendingChanges = $0.pendingChanges .filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo } } @@ -719,7 +696,7 @@ public final class OpenGroupManager { messages: [OpenGroupAPI.DirectMessage], fromOutbox: Bool, on server: String, - dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies ) { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return } @@ -762,7 +739,7 @@ public final class OpenGroupManager { data: messageData, isOutgoing: fromOutbox, otherBlindedPublicKey: (fromOutbox ? message.recipient : message.sender), - dependencies: dependencies + using: dependencies ) // We want to update the BlindedIdLookup cache with the message info so we can avoid using the @@ -788,7 +765,7 @@ public final class OpenGroupManager { openGroupServer: server.lowercased(), openGroupPublicKey: openGroup.publicKey, isCheckingForOutbox: fromOutbox, - dependencies: dependencies + using: dependencies ) }() lookupCache[message.recipient] = lookup @@ -817,7 +794,7 @@ public final class OpenGroupManager { message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - dependencies: dependencies + using: dependencies ) } } @@ -846,7 +823,7 @@ public final class OpenGroupManager { in roomToken: String, on server: String, type: OpenGroupAPI.PendingChange.ReactAction, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> OpenGroupAPI.PendingChange { let pendingChange = OpenGroupAPI.PendingChange( server: server, @@ -859,7 +836,7 @@ public final class OpenGroupManager { ) ) - dependencies.mutableCache.mutate { + dependencies.caches.mutate(cache: .openGroupManager) { $0.pendingChanges.append(pendingChange) } @@ -869,9 +846,9 @@ public final class OpenGroupManager { public static func updatePendingChange( _ pendingChange: OpenGroupAPI.PendingChange, seqNo: Int64?, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) { - dependencies.mutableCache.mutate { + dependencies.caches.mutate(cache: .openGroupManager) { if let index = $0.pendingChanges.firstIndex(of: pendingChange) { $0.pendingChanges[index].seqNo = seqNo } @@ -880,9 +857,9 @@ public final class OpenGroupManager { public static func removePendingChange( _ pendingChange: OpenGroupAPI.PendingChange, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) { - dependencies.mutableCache.mutate { + dependencies.caches.mutate(cache: .openGroupManager) { if let index = $0.pendingChanges.firstIndex(of: pendingChange) { $0.pendingChanges.remove(at: index) } @@ -894,7 +871,7 @@ public final class OpenGroupManager { _ db: Database? = nil, capability: Capability.Variant, on server: String?, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> Bool { guard let server: String = server else { return false } guard let db: Database = db else { @@ -919,7 +896,7 @@ public final class OpenGroupManager { _ publicKey: String, for roomToken: String?, on server: String?, - using dependencies: OGMDependencies = OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> Bool { guard let roomToken: String = roomToken, let server: String = server else { return false } @@ -943,7 +920,7 @@ public final class OpenGroupManager { // Conveniently the logic for these different cases works in order so we can fallthrough each // case with only minor efficiency losses - let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) switch sessionId.prefix { case .standard: @@ -967,10 +944,12 @@ public final class OpenGroupManager { .filter(id: groupId) .asRequest(of: String.self) .fetchOne(db), - let blindedKeyPair: KeyPair = dependencies.sodium.blindedKeyPair( - serverPublicKey: openGroupPublicKey, - edKeyPair: userEdKeyPair, - genericHash: dependencies.genericHash + let blindedKeyPair: KeyPair = dependencies.crypto.generate( + .blindedKeyPair( + serverPublicKey: openGroupPublicKey, + edKeyPair: userEdKeyPair, + using: dependencies + ) ) else { return false } guard @@ -1003,19 +982,16 @@ public final class OpenGroupManager { } @discardableResult public static func getDefaultRoomsIfNeeded( - using dependencies: OGMDependencies = OGMDependencies( - subscribeQueue: OpenGroupAPI.workQueue, - receiveQueue: OpenGroupAPI.workQueue - ) + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[DefaultRoomInfo], Error> { // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again - if let existingPublisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.cache.defaultRoomsPublisher { + if let existingPublisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.caches[.openGroupManager].defaultRoomsPublisher { return existingPublisher } // Try to retrieve the default rooms 8 times let publisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.storage - .readPublisher { db in + .readPublisher { db -> OpenGroupAPI.PreparedSendData in try OpenGroupAPI.preparedCapabilitiesAndRooms( db, on: OpenGroupAPI.defaultServer, @@ -1023,9 +999,9 @@ public final class OpenGroupManager { ) } .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .subscribe(on: dependencies.subscribeQueue) - .receive(on: dependencies.receiveQueue) - .retry(8) + .subscribe(on: OpenGroupAPI.workQueue, using: dependencies) + .receive(on: OpenGroupAPI.workQueue, using: dependencies) + .retry(8, using: dependencies) .map { info, response -> [DefaultRoomInfo]? in dependencies.storage.write { db -> [DefaultRoomInfo] in // Store the capabilities first @@ -1090,7 +1066,7 @@ public final class OpenGroupManager { switch result { case .finished: break case .failure: - dependencies.mutableCache.mutate { cache in + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.defaultRoomsPublisher = nil } } @@ -1099,7 +1075,7 @@ public final class OpenGroupManager { .shareReplay(1) .eraseToAnyPublisher() - dependencies.mutableCache.mutate { cache in + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.defaultRoomsPublisher = publisher } @@ -1114,9 +1090,7 @@ public final class OpenGroupManager { for roomToken: String, on server: String, existingData: Data?, - using dependencies: OGMDependencies = OGMDependencies( - subscribeQueue: .global(qos: .background) - ) + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { // Normally the image for a given group is stored with the group thread, so it's only // fetched once. However, on the join open group screen we show images for groups the @@ -1129,7 +1103,7 @@ public final class OpenGroupManager { // there is one. let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) let lastOpenGroupImageUpdate: Date? = dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] - let now: Date = dependencies.date + let now: Date = dependencies.dateNow let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) let updateInterval: TimeInterval = (7 * 24 * 60 * 60) let canUseExistingImage: Bool = ( @@ -1143,14 +1117,14 @@ public final class OpenGroupManager { .eraseToAnyPublisher() } - if let publisher: AnyPublisher = dependencies.cache.groupImagePublishers[threadId] { + if let publisher: AnyPublisher = dependencies.caches[.openGroupManager].groupImagePublishers[threadId] { return publisher } // Defer the actual download and run it on a separate thread to avoid blocking the calling thread let publisher: AnyPublisher = Deferred { Future { resolver in - dependencies.subscribeQueue.async { + DispatchQueue.global(qos: .background).async(using: dependencies) { // Hold on to the publisher until it has completed at least once dependencies.storage .readPublisher { db -> (Data?, OpenGroupAPI.PreparedSendData?) in @@ -1224,10 +1198,10 @@ public final class OpenGroupManager { // Automatically subscribe for the roomImage download (want to download regardless of // whether the upstream subscribes) publisher - .subscribe(on: dependencies.subscribeQueue) + .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) .sinkUntilComplete() - dependencies.mutableCache.mutate { cache in + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.groupImagePublishers[threadId] = publisher } @@ -1235,9 +1209,65 @@ public final class OpenGroupManager { } } +// MARK: - OpenGroupManager Cache + +public extension OpenGroupManager { + class Cache: OGMCacheType { + public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error>? + public var groupImagePublishers: [String: AnyPublisher] = [:] + + public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server + public var isPolling: Bool = false + + /// Server URL to value + public var hasPerformedInitialPoll: [String: Bool] = [:] + public var timeSinceLastPoll: [String: TimeInterval] = [:] + + fileprivate var _timeSinceLastOpen: TimeInterval? + public func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval { + if let storedTimeSinceLastOpen: TimeInterval = _timeSinceLastOpen { + return storedTimeSinceLastOpen + } + + guard let lastOpen: Date = dependencies.standardUserDefaults[.lastOpen] else { + _timeSinceLastOpen = .greatestFiniteMagnitude + return .greatestFiniteMagnitude + } + + _timeSinceLastOpen = dependencies.dateNow.timeIntervalSince(lastOpen) + return dependencies.dateNow.timeIntervalSince(lastOpen) + } + + public var pendingChanges: [OpenGroupAPI.PendingChange] = [] + } +} + +public extension Cache { + static let openGroupManager: CacheInfo.Config = CacheInfo.create( + createInstance: { OpenGroupManager.Cache() }, + mutableInstance: { $0 }, + immutableInstance: { $0 } + ) +} + // MARK: - OGMCacheType -public protocol OGMMutableCacheType: OGMCacheType { +/// This is a read-only version of the `OpenGroupManager.Cache` designed to avoid unintentionally mutating the instance in a +/// non-thread-safe way +public protocol OGMImmutableCacheType: ImmutableCacheType { + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get } + var groupImagePublishers: [String: AnyPublisher] { get } + + var pollers: [String: OpenGroupAPI.Poller] { get } + var isPolling: Bool { get } + + var hasPerformedInitialPoll: [String: Bool] { get } + var timeSinceLastPoll: [String: TimeInterval] { get } + + var pendingChanges: [OpenGroupAPI.PendingChange] { get } +} + +public protocol OGMCacheType: OGMImmutableCacheType, MutableCacheType { var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get set } var groupImagePublishers: [String: AnyPublisher] { get set } @@ -1251,90 +1281,3 @@ public protocol OGMMutableCacheType: OGMCacheType { func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval } - -/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a -/// non-thread-safe way -public protocol OGMCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get } - var groupImagePublishers: [String: AnyPublisher] { get } - - var pollers: [String: OpenGroupAPI.Poller] { get } - var isPolling: Bool { get } - - var hasPerformedInitialPoll: [String: Bool] { get } - var timeSinceLastPoll: [String: TimeInterval] { get } - - var pendingChanges: [OpenGroupAPI.PendingChange] { get } -} - -// MARK: - OGMDependencies - -extension OpenGroupManager { - public class OGMDependencies: SMKDependencies { - /// These should not be accessed directly but rather via an instance of this type - private static let _cacheInstance: OGMMutableCacheType = OpenGroupManager.Cache() - private static let _cacheInstanceAccessQueue = DispatchQueue(label: "OGMCacheInstanceAccess") - - internal var _mutableCache: Atomic - public var mutableCache: Atomic { - get { - Dependencies.getMutableValueSettingIfNull(&_mutableCache) { - OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance } - } - } - } - public var cache: OGMCacheType { - get { - Dependencies.getValueSettingIfNull(&_mutableCache) { - OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance } - } - } - set { - guard let mutableValue: OGMMutableCacheType = newValue as? OGMMutableCacheType else { return } - - _mutableCache.mutate { $0 = mutableValue } - } - } - - public init( - subscribeQueue: DispatchQueue? = nil, - receiveQueue: DispatchQueue? = nil, - cache: OGMMutableCacheType? = nil, - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - sodium: SodiumType? = nil, - box: BoxType? = nil, - genericHash: GenericHashType? = nil, - sign: SignType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - ed25519: Ed25519Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) { - _mutableCache = Atomic(cache) - - super.init( - subscribeQueue: subscribeQueue, - receiveQueue: receiveQueue, - onionApi: onionApi, - generalCache: generalCache, - storage: storage, - scheduler: scheduler, - sodium: sodium, - box: box, - genericHash: genericHash, - sign: sign, - aeadXChaCha20Poly1305Ietf: aeadXChaCha20Poly1305Ietf, - ed25519: ed25519, - nonceGenerator16: nonceGenerator16, - nonceGenerator24: nonceGenerator24, - standardUserDefaults: standardUserDefaults, - date: date - ) - } - } -} diff --git a/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift b/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift deleted file mode 100644 index 50bcf5db9..000000000 --- a/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Sodium - -public protocol NonceGenerator16ByteType { - var NonceBytes: Int { get } - - func nonce() -> Array -} - -public protocol NonceGenerator24ByteType { - var NonceBytes: Int { get } - - func nonce() -> Array -} - -extension OpenGroupAPI { - public class NonceGenerator16Byte: NonceGenerator, NonceGenerator16ByteType { - public var NonceBytes: Int { 16 } - } - - public class NonceGenerator24Byte: NonceGenerator, NonceGenerator24ByteType { - public var NonceBytes: Int { 24 } - } -} diff --git a/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift index c8b7c4d00..d8bb312f7 100644 --- a/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift +++ b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift @@ -29,7 +29,7 @@ public extension OpenGroupAPI { private let method: HTTPMethod private let path: String public let endpoint: Endpoint - fileprivate let batchEndpoints: [Endpoint] + internal let batchEndpoints: [Endpoint] public let batchResponseTypes: [Decodable.Type] /// The `jsonBodyEncoder` is used to simplify the encoding for `BatchRequest` @@ -185,7 +185,7 @@ public extension OpenGroupAPI.PreparedSendData { public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error { func decoded( with preparedData: OpenGroupAPI.PreparedSendData, - using dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) -> AnyPublisher<(ResponseInfoType, R), Error> { self .tryMap { responseInfo, maybeData -> (ResponseInfoType, R) in diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift deleted file mode 100644 index 223a42e44..000000000 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import Curve25519Kit -import SessionUtilitiesKit - -public protocol SodiumType { - func getBox() -> BoxType - func getGenericHash() -> GenericHashType - func getSign() -> SignType - func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType - - func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? - func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair, genericHash: GenericHashType) -> KeyPair? - func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? - - func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? - func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? - - func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String, genericHash: GenericHashType) -> Bool -} - -public protocol AeadXChaCha20Poly1305IetfType { - var KeyBytes: Int { get } - var ABytes: Int { get } - - func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? - func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? -} - -public protocol Ed25519Type { - func sign(data: Bytes, keyPair: KeyPair) throws -> Bytes? - func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool -} - -public protocol BoxType { - func seal(message: Bytes, recipientPublicKey: Bytes) -> Bytes? - func open(anonymousCipherText: Bytes, recipientPublicKey: Bytes, recipientSecretKey: Bytes) -> Bytes? -} - -public protocol GenericHashType { - func hash(message: Bytes, key: Bytes?) -> Bytes? - func hash(message: Bytes, outputLength: Int) -> Bytes? - func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? -} - -public protocol SignType { - var Bytes: Int { get } - var PublicKeyBytes: Int { get } - - func toX25519(ed25519PublicKey: Bytes) -> Bytes? - func signature(message: Bytes, secretKey: Bytes) -> Bytes? - func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool -} - -// MARK: - Default Values - -extension GenericHashType { - func hash(message: Bytes) -> Bytes? { return hash(message: message, key: nil) } - - func hashSaltPersonal(message: Bytes, outputLength: Int, salt: Bytes, personal: Bytes) -> Bytes? { - return hashSaltPersonal(message: message, outputLength: outputLength, key: nil, salt: salt, personal: personal) - } -} - -extension AeadXChaCha20Poly1305IetfType { - func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? { - return encrypt(message: message, secretKey: secretKey, nonce: nonce, additionalData: nil) - } - - func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? { - return decrypt(authenticatedCipherText: authenticatedCipherText, secretKey: secretKey, nonce: nonce, additionalData: nil) - } -} - -// MARK: - Conformance - -extension Sodium: SodiumType { - public func getBox() -> BoxType { return box } - public func getGenericHash() -> GenericHashType { return genericHash } - public func getSign() -> SignType { return sign } - public func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return aead.xchacha20poly1305ietf } - - public func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair) -> KeyPair? { - return blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: edKeyPair, genericHash: getGenericHash()) - } -} - -extension Box: BoxType {} -extension GenericHash: GenericHashType {} -extension Sign: SignType {} -extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} - -struct Ed25519Wrapper: Ed25519Type { - func sign(data: Bytes, keyPair: KeyPair) throws -> Bytes? { - let ecKeyPair: ECKeyPair = try ECKeyPair( - publicKeyData: Data(keyPair.publicKey), - privateKeyData: Data(keyPair.secretKey) - ) - - return try Ed25519.sign(Data(data), with: ecKeyPair).bytes - } - - func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { - return try Ed25519.verifySignature(signature, publicKey: publicKey, data: data) - } -} diff --git a/SessionMessagingKit/SMKDependencies.swift b/SessionMessagingKit/SMKDependencies.swift deleted file mode 100644 index 34b9b9e6d..000000000 --- a/SessionMessagingKit/SMKDependencies.swift +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import Sodium -import SessionSnodeKit -import SessionUtilitiesKit - -public class SMKDependencies: SSKDependencies { - internal var _sodium: Atomic - public var sodium: SodiumType { - get { Dependencies.getValueSettingIfNull(&_sodium) { Sodium() } } - set { _sodium.mutate { $0 = newValue } } - } - - internal var _box: Atomic - public var box: BoxType { - get { Dependencies.getValueSettingIfNull(&_box) { sodium.getBox() } } - set { _box.mutate { $0 = newValue } } - } - - internal var _genericHash: Atomic - public var genericHash: GenericHashType { - get { Dependencies.getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } - set { _genericHash.mutate { $0 = newValue } } - } - - internal var _sign: Atomic - public var sign: SignType { - get { Dependencies.getValueSettingIfNull(&_sign) { sodium.getSign() } } - set { _sign.mutate { $0 = newValue } } - } - - internal var _aeadXChaCha20Poly1305Ietf: Atomic - public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { - get { Dependencies.getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } - set { _aeadXChaCha20Poly1305Ietf.mutate { $0 = newValue } } - } - - internal var _ed25519: Atomic - public var ed25519: Ed25519Type { - get { Dependencies.getValueSettingIfNull(&_ed25519) { Ed25519Wrapper() } } - set { _ed25519.mutate { $0 = newValue } } - } - - internal var _nonceGenerator16: Atomic - public var nonceGenerator16: NonceGenerator16ByteType { - get { Dependencies.getValueSettingIfNull(&_nonceGenerator16) { OpenGroupAPI.NonceGenerator16Byte() } } - set { _nonceGenerator16.mutate { $0 = newValue } } - } - - internal var _nonceGenerator24: Atomic - public var nonceGenerator24: NonceGenerator24ByteType { - get { Dependencies.getValueSettingIfNull(&_nonceGenerator24) { OpenGroupAPI.NonceGenerator24Byte() } } - set { _nonceGenerator24.mutate { $0 = newValue } } - } - - // MARK: - Initialization - - public init( - subscribeQueue: DispatchQueue? = nil, - receiveQueue: DispatchQueue? = nil, - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - sodium: SodiumType? = nil, - box: BoxType? = nil, - genericHash: GenericHashType? = nil, - sign: SignType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - ed25519: Ed25519Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) { - _sodium = Atomic(sodium) - _box = Atomic(box) - _genericHash = Atomic(genericHash) - _sign = Atomic(sign) - _aeadXChaCha20Poly1305Ietf = Atomic(aeadXChaCha20Poly1305Ietf) - _ed25519 = Atomic(ed25519) - _nonceGenerator16 = Atomic(nonceGenerator16) - _nonceGenerator24 = Atomic(nonceGenerator24) - - super.init( - subscribeQueue: subscribeQueue, - receiveQueue: receiveQueue, - onionApi: onionApi, - generalCache: generalCache, - storage: storage, - scheduler: scheduler, - standardUserDefaults: standardUserDefaults, - date: date - ) - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift index 12cb06900..b2408a869 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift @@ -11,6 +11,7 @@ public enum MessageSenderError: LocalizedError, Equatable { case encryptionFailed case noUsername case attachmentsNotUploaded + case blindingFailed // Closed groups case noThread @@ -21,7 +22,10 @@ public enum MessageSenderError: LocalizedError, Equatable { internal var isRetryable: Bool { switch self { - case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, .signingFailed, .encryptionFailed: return false + case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, + .signingFailed, .encryptionFailed, .blindingFailed: + return false + default: return true } } @@ -36,6 +40,7 @@ public enum MessageSenderError: LocalizedError, Equatable { case .encryptionFailed: return "Couldn't encrypt message." case .noUsername: return "Missing username." case .attachmentsNotUploaded: return "Attachments for this message have not been uploaded." + case .blindingFailed: return "Couldn't blind the sender" // Closed groups case .noThread: return "Couldn't find a thread associated with the given group public key." @@ -58,6 +63,7 @@ public enum MessageSenderError: LocalizedError, Equatable { case (.noThread, .noThread): return true case (.noKeyPair, .noKeyPair): return true case (.invalidClosedGroupUpdate, .invalidClosedGroupUpdate): return true + case (.blindingFailed, .blindingFailed): return true case (.other(let lhsError), .other(let rhsError)): // Not ideal but the best we can do diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index cd85c095b..270e27d41 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -194,7 +194,11 @@ extension MessageReceiver { // MARK: - Convenience - public static func handleIncomingCallOfferInBusyState(_ db: Database, message: CallMessage) throws { + public static func handleIncomingCallOfferInBusyState( + _ db: Database, + message: CallMessage, + using dependencies: Dependencies = Dependencies() + ) throws { let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed) guard @@ -229,7 +233,7 @@ extension MessageReceiver { .inserted(db) MessageSender.sendImmediate( - preparedSendData: try MessageSender + data: try MessageSender .preparedSendData( db, message: CallMessage( @@ -242,8 +246,10 @@ extension MessageReceiver { namespace: try Message.Destination .from(db, threadId: thread.id, threadVariant: thread.variant) .defaultNamespace, - interactionId: nil // Explicitly nil as it's a separate message from above - ) + interactionId: nil, // Explicitly nil as it's a separate message from above + using: dependencies + ), + using: dependencies ) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index 1a13665af..22a28658c 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -12,10 +12,11 @@ extension MessageReceiver { _ db: Database, threadId: String, threadVariant: SessionThread.Variant, - message: ClosedGroupControlMessage + message: ClosedGroupControlMessage, + using dependencies: Dependencies = Dependencies() ) throws { switch message.kind { - case .new: try handleNewClosedGroup(db, message: message) + case .new: try handleNewClosedGroup(db, message: message, using: dependencies) case .encryptionKeyPair: try handleClosedGroupEncryptionKeyPair( @@ -65,7 +66,11 @@ extension MessageReceiver { // MARK: - Specific Handling - private static func handleNewClosedGroup(_ db: Database, message: ClosedGroupControlMessage) throws { + private static func handleNewClosedGroup( + _ db: Database, + message: ClosedGroupControlMessage, + using dependencies: Dependencies + ) throws { guard case let .new(publicKeyAsData, name, encryptionKeyPair, membersAsData, adminsAsData, expirationTimer) = message.kind else { return } @@ -112,7 +117,8 @@ extension MessageReceiver { admins: adminsAsData.map { $0.toHexString() }, expirationTimer: expirationTimer, formationTimestampMs: sentTimestamp, - calledFromConfigHandling: false + calledFromConfigHandling: false, + using: dependencies ) } @@ -125,7 +131,8 @@ extension MessageReceiver { admins: [String], expirationTimer: UInt32, formationTimestampMs: UInt64, - calledFromConfigHandling: Bool + calledFromConfigHandling: Bool, + using dependencies: Dependencies ) throws { // With new closed groups we only want to create them if the admin creating the closed group is an // approved contact (to prevent spam via closed groups getting around message requests if users are @@ -222,7 +229,7 @@ extension MessageReceiver { } // Start polling - ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey) + ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey, using: dependencies) // Notify the PN server let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey(db)) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index 1935d7619..390bf8381 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -7,7 +7,11 @@ import SessionUIKit import SessionUtilitiesKit extension MessageReceiver { - internal static func handleLegacyConfigurationMessage(_ db: Database, message: ConfigurationMessage) throws { + internal static func handleLegacyConfigurationMessage( + _ db: Database, + message: ConfigurationMessage, + using dependencies: Dependencies + ) throws { // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent guard !SessionUtil.userConfigsEnabled(db) else { TopBannerController.show(warning: .outdatedUserConfig) @@ -46,7 +50,8 @@ extension MessageReceiver { ) }(), sentTimestamp: messageSentTimestamp, - calledFromConfigHandling: true + calledFromConfigHandling: true, + using: dependencies ) // Create a contact for the current user if needed (also force-approve the current user @@ -192,7 +197,8 @@ extension MessageReceiver { admins: [String](closedGroup.admins), expirationTimer: closedGroup.expirationTimer, formationTimestampMs: message.sentTimestamp!, - calledFromConfigHandling: false // Legacy config isn't an issue + calledFromConfigHandling: false, // Legacy config isn't an issue + using: dependencies ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 1582d5896..10dc0dde4 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -10,9 +10,9 @@ extension MessageReceiver { internal static func handleMessageRequestResponse( _ db: Database, message: MessageRequestResponse, - dependencies: SMKDependencies + using dependencies: Dependencies ) throws { - let userPublicKey = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey = getUserHexEncodedPublicKey(db, using: dependencies) var blindedContactIds: [String] = [] // Ignore messages which were sent from the current user @@ -42,7 +42,8 @@ extension MessageReceiver { fileName: nil ) }(), - sentTimestamp: messageSentTimestamp + sentTimestamp: messageSentTimestamp, + using: dependencies ) } @@ -73,11 +74,13 @@ extension MessageReceiver { // If the sessionId matches the blindedId then this thread needs to be converted to an // un-blinded thread guard - dependencies.sodium.sessionId( - senderId, - matchesBlindedId: blindedIdLookup.blindedId, - serverPublicKey: blindedIdLookup.openGroupPublicKey, - genericHash: dependencies.genericHash + dependencies.crypto.verify( + .sessionId( + senderId, + matchesBlindedId: blindedIdLookup.blindedId, + serverPublicKey: blindedIdLookup.openGroupPublicKey, + using: dependencies + ) ) else { return } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 7f972a4c3..a1a959306 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -13,7 +13,7 @@ extension MessageReceiver { threadVariant: SessionThread.Variant, message: VisibleMessage, associatedWithProto proto: SNProtoContent, - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) throws -> Int64 { guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { throw MessageReceiverError.invalidMessage @@ -43,7 +43,8 @@ extension MessageReceiver { fileName: nil ) }(), - sentTimestamp: messageSentTimestamp + sentTimestamp: messageSentTimestamp, + using: dependencies ) } @@ -64,7 +65,7 @@ extension MessageReceiver { } // Store the message variant so we can run variant-specific behaviours - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let thread: SessionThread = try SessionThread .fetchOrCreate(db, id: threadId, variant: threadVariant, shouldBeVisible: nil) let maybeOpenGroup: OpenGroup? = { @@ -90,10 +91,12 @@ extension MessageReceiver { guard let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), - let blindedKeyPair: KeyPair = sodium.blindedKeyPair( - serverPublicKey: openGroup.publicKey, - edKeyPair: userEdKeyPair, - genericHash: sodium.genericHash + let blindedKeyPair: KeyPair = try? dependencies.crypto.generate( + .blindedKeyPair( + serverPublicKey: openGroup.publicKey, + edKeyPair: userEdKeyPair, + using: dependencies + ) ) else { return .standardIncoming } @@ -163,7 +166,8 @@ extension MessageReceiver { db, threadId: thread.id, body: message.text, - quoteAuthorId: dataMessage.quote?.author + quoteAuthorId: dataMessage.quote?.author, + using: dependencies ), // Note: Ensure we don't ever expire open group messages expiresInSeconds: (disappearingMessagesConfiguration.isEnabled && message.openGroupServerMessageId == nil ? @@ -294,7 +298,7 @@ extension MessageReceiver { .appending(quote?.attachmentId) .appending(linkPreview?.attachmentId) .forEach { attachmentId in - JobRunner.add( + dependencies.jobRunner.add( db, job: Job( variant: .attachmentDownload, @@ -304,7 +308,8 @@ extension MessageReceiver { attachmentId: attachmentId ) ), - canStartJob: isMainAppActive + canStartJob: isMainAppActive, + using: dependencies ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index 5cecfca71..19841fd95 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -4,7 +4,6 @@ import Foundation import Combine import GRDB import Sodium -import Curve25519Kit import SessionUtilitiesKit import SessionSnodeKit @@ -13,21 +12,21 @@ extension MessageSender { public static func createClosedGroup( name: String, - members: Set + members: Set, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { - Storage.shared + dependencies.storage .writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData]) in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - var members: Set = members + // Generate the group's two keys + guard + let groupKeyPair: KeyPair = dependencies.crypto.generate(.x25519KeyPair()), + let encryptionKeyPair: KeyPair = dependencies.crypto.generate(.x25519KeyPair()) + else { throw MessageSenderError.noKeyPair } - // Generate the group's public key - let groupKeyPair: ECKeyPair = Curve25519.generateKeyPair() - let groupPublicKey: String = KeyPair( - publicKey: groupKeyPair.publicKey.bytes, - secretKey: groupKeyPair.privateKey.bytes - ).hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix - // Generate the key pair that'll be used for encryption and decryption - let encryptionKeyPair: ECKeyPair = Curve25519.generateKeyPair() + // Includes the 'SessionId.Prefix.standard' prefix + let groupPublicKey: String = groupKeyPair.hexEncodedPublicKey + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) + var members: Set = members // Create the group members.insert(userPublicKey) // Ensure the current user is included in the member list @@ -49,8 +48,8 @@ extension MessageSender { let latestKeyPairReceivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) try ClosedGroupKeyPair( threadId: groupPublicKey, - publicKey: encryptionKeyPair.publicKey, - secretKey: encryptionKeyPair.privateKey, + publicKey: Data(encryptionKeyPair.publicKey), + secretKey: Data(encryptionKeyPair.secretKey), receivedTimestamp: latestKeyPairReceivedTimestamp ).insert(db) @@ -78,8 +77,8 @@ extension MessageSender { db, groupPublicKey: groupPublicKey, name: name, - latestKeyPairPublicKey: encryptionKeyPair.publicKey, - latestKeyPairSecretKey: encryptionKeyPair.privateKey, + latestKeyPairPublicKey: Data(encryptionKeyPair.publicKey), + latestKeyPairSecretKey: Data(encryptionKeyPair.secretKey), latestKeyPairReceivedTimestamp: latestKeyPairReceivedTimestamp, disappearingConfig: DisappearingMessagesConfiguration.defaultWith(groupPublicKey), members: members, @@ -94,10 +93,7 @@ extension MessageSender { kind: .new( publicKey: Data(hex: groupPublicKey), name: name, - encryptionKeyPair: KeyPair( - publicKey: encryptionKeyPair.publicKey.bytes, - secretKey: encryptionKeyPair.privateKey.bytes - ), + encryptionKeyPair: encryptionKeyPair, members: membersAsData, admins: adminsAsData, expirationTimer: 0 @@ -108,7 +104,8 @@ extension MessageSender { ), to: .contact(publicKey: memberId), namespace: Message.Destination.contact(publicKey: memberId).defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) } @@ -119,7 +116,7 @@ extension MessageSender { .MergeMany( // Send a closed group update message to all members individually memberSendData - .map { MessageSender.sendImmediate(preparedSendData: $0) } + .map { MessageSender.sendImmediate(data: $0, using: dependencies) } .appending( // Notify the PN server PushNotificationAPI.performOperation( @@ -135,7 +132,7 @@ extension MessageSender { .handleEvents( receiveOutput: { thread in // Start polling - ClosedGroupPoller.shared.startIfNeeded(for: thread.id) + ClosedGroupPoller.shared.startIfNeeded(for: thread.id, using: dependencies) } ) .eraseToAnyPublisher() @@ -151,21 +148,25 @@ extension MessageSender { targetMembers: Set, userPublicKey: String, allGroupMembers: [GroupMember], - closedGroup: ClosedGroup + closedGroup: ClosedGroup, + using dependencies: Dependencies ) -> AnyPublisher { guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else { return Fail(error: MessageSenderError.invalidClosedGroupUpdate) .eraseToAnyPublisher() } - return Storage.shared + return dependencies.storage .readPublisher { db -> (ClosedGroupKeyPair, MessageSender.PreparedSendData) in // Generate the new encryption key pair - let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair() + guard let legacyNewKeyPair: KeyPair = dependencies.crypto.generate(.x25519KeyPair()) else { + throw MessageSenderError.noKeyPair + } + let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair( threadId: closedGroup.threadId, - publicKey: legacyNewKeyPair.publicKey, - secretKey: legacyNewKeyPair.privateKey, + publicKey: Data(legacyNewKeyPair.publicKey), + secretKey: Data(legacyNewKeyPair.secretKey), receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) ) @@ -193,7 +194,8 @@ extension MessageSender { encryptedKeyPair: try MessageSender.encryptWithSessionProtocol( db, plaintext: plaintext, - for: memberPublicKey + for: memberPublicKey, + using: dependencies ) ) } @@ -204,20 +206,21 @@ extension MessageSender { namespace: try Message.Destination .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup) .defaultNamespace, - interactionId: nil + interactionId: nil, + using: dependencies ) return (newKeyPair, sendData) } .flatMap { newKeyPair, sendData -> AnyPublisher in - MessageSender.sendImmediate(preparedSendData: sendData) + MessageSender.sendImmediate(data: sendData, using: dependencies) .map { _ in newKeyPair } .eraseToAnyPublisher() } .handleEvents( receiveOutput: { newKeyPair in /// Store it **after** having sent out the message to the group - Storage.shared.write { db in + dependencies.storage.write { db in try newKeyPair.insert(db) // Update libSession @@ -251,11 +254,12 @@ extension MessageSender { public static func update( groupPublicKey: String, with members: Set, - name: String + name: String, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { return Storage.shared .writePublisher { db -> (String, ClosedGroup, [GroupMember], Set) in - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) // Get the group, check preconditions & prepare guard (try? SessionThread.exists(db, id: groupPublicKey)) == true else { @@ -292,7 +296,8 @@ extension MessageSender { message: ClosedGroupControlMessage(kind: .nameChange(name: name)), interactionId: interactionId, threadId: groupPublicKey, - threadVariant: .legacyGroup + threadVariant: .legacyGroup, + using: dependencies ) // Update libSession @@ -321,7 +326,8 @@ extension MessageSender { addedMembers: addedMembers, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, - closedGroup: closedGroup + closedGroup: closedGroup, + using: dependencies ) } catch { @@ -348,7 +354,8 @@ extension MessageSender { removedMembers: removedMembers, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, - closedGroup: closedGroup + closedGroup: closedGroup, + using: dependencies ) .catch { _ in Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() } .eraseToAnyPublisher() @@ -364,7 +371,8 @@ extension MessageSender { addedMembers: Set, userPublicKey: String, allGroupMembers: [GroupMember], - closedGroup: ClosedGroup + closedGroup: ClosedGroup, + using dependencies: Dependencies ) throws { guard let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration.fetchOne(db, id: closedGroup.threadId) else { throw StorageError.objectNotFound @@ -419,7 +427,8 @@ extension MessageSender { ), interactionId: interactionId, threadId: closedGroup.threadId, - threadVariant: .legacyGroup + threadVariant: .legacyGroup, + using: dependencies ) try addedMembers.forEach { member in @@ -446,7 +455,8 @@ extension MessageSender { ), interactionId: nil, threadId: member, - threadVariant: .contact + threadVariant: .contact, + using: dependencies ) // Add the users to the group @@ -469,7 +479,8 @@ extension MessageSender { removedMembers: Set, userPublicKey: String, allGroupMembers: [GroupMember], - closedGroup: ClosedGroup + closedGroup: ClosedGroup, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard !removedMembers.contains(userPublicKey) else { SNLog("Invalid closed group update.") @@ -490,7 +501,7 @@ extension MessageSender { .map { $0.profileId } let members: Set = Set(groupMemberIds).subtracting(removedMembers) - return Storage.shared + return dependencies.storage .writePublisher { db in // Update zombie & member list try GroupMember @@ -535,16 +546,18 @@ extension MessageSender { namespace: try Message.Destination .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup) .defaultNamespace, - interactionId: interactionId + interactionId: interactionId, + using: dependencies ) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .flatMap { _ -> AnyPublisher in MessageSender.generateAndSendNewEncryptionKeyPair( targetMembers: members, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, - closedGroup: closedGroup + closedGroup: closedGroup, + using: dependencies ) } .eraseToAnyPublisher() @@ -561,9 +574,10 @@ extension MessageSender { public static func leave( _ db: Database, groupPublicKey: String, - deleteThread: Bool + deleteThread: Bool, + using dependencies: Dependencies = Dependencies() ) throws { - let userPublicKey: String = getUserHexEncodedPublicKey(db) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) // Notify the user let interaction: Interaction = try Interaction( @@ -574,7 +588,7 @@ extension MessageSender { timestampMs: SnodeAPI.currentOffsetTimestampMs() ).inserted(db) - JobRunner.upsert( + dependencies.jobRunner.upsert( db, job: Job( variant: .groupLeaving, @@ -583,14 +597,17 @@ extension MessageSender { details: GroupLeavingJob.Details( deleteThread: deleteThread ) - ) + ), + canStartJob: true, + using: dependencies ) } public static func sendLatestEncryptionKeyPair( _ db: Database, to publicKey: String, - for groupPublicKey: String + for groupPublicKey: String, + using dependencies: Dependencies = Dependencies() ) { guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { return SNLog("Couldn't send key pair for nonexistent closed group.") @@ -626,7 +643,8 @@ extension MessageSender { let ciphertext = try MessageSender.encryptWithSessionProtocol( db, plaintext: plaintext, - for: publicKey + for: publicKey, + using: dependencies ) SNLog("Sending latest encryption key pair to: \(publicKey).") @@ -645,7 +663,8 @@ extension MessageSender { ), interactionId: nil, threadId: thread.id, - threadVariant: thread.variant + threadVariant: thread.variant, + using: dependencies ) } catch {} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 06780bcaf..08449a494 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -6,18 +6,24 @@ import Sodium import SessionUtilitiesKit extension MessageReceiver { - internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: KeyPair, dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { - let recipientX25519PrivateKey = x25519KeyPair.secretKey - let recipientX25519PublicKey = x25519KeyPair.publicKey - let signatureSize = dependencies.sign.Bytes - let ed25519PublicKeySize = dependencies.sign.PublicKeyBytes + internal static func decryptWithSessionProtocol( + ciphertext: Data, + using x25519KeyPair: KeyPair, + using dependencies: Dependencies = Dependencies() + ) throws -> (plaintext: Data, senderX25519PublicKey: String) { + let recipientX25519PrivateKey: Bytes = x25519KeyPair.secretKey + let recipientX25519PublicKey: Bytes = x25519KeyPair.publicKey + let signatureSize: Int = dependencies.crypto.size(.signature) + let ed25519PublicKeySize: Int = dependencies.crypto.size(.publicKey) // 1. ) Decrypt the message guard - let plaintextWithMetadata = dependencies.box.open( - anonymousCipherText: Bytes(ciphertext), - recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)), - recipientSecretKey: Bytes(recipientX25519PrivateKey) + let plaintextWithMetadata = try? dependencies.crypto.perform( + .open( + anonymousCipherText: Bytes(ciphertext), + recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)), + recipientSecretKey: Bytes(recipientX25519PrivateKey) + ) ), plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) else { @@ -32,79 +38,100 @@ extension MessageReceiver { // 3. ) Verify the signature let verificationData = plaintext + senderED25519PublicKey + recipientX25519PublicKey - guard dependencies.sign.verify(message: verificationData, publicKey: senderED25519PublicKey, signature: signature) else { - throw MessageReceiverError.invalidSignature - } + guard + dependencies.crypto.verify( + .signature(message: verificationData, publicKey: senderED25519PublicKey, signature: signature) + ) + else { throw MessageReceiverError.invalidSignature } // 4. ) Get the sender's X25519 public key - guard let senderX25519PublicKey = dependencies.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { - throw MessageReceiverError.decryptionFailed - } + guard + let senderX25519PublicKey = try? dependencies.crypto.perform( + .toX25519(ed25519PublicKey: senderED25519PublicKey) + ) + else { throw MessageReceiverError.decryptionFailed } return (Data(plaintext), SessionId(.standard, publicKey: senderX25519PublicKey).hexString) } - internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: KeyPair, using dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + internal static func decryptWithSessionBlindingProtocol( + data: Data, + isOutgoing: Bool, + otherBlindedPublicKey: String, + with openGroupPublicKey: String, + userEd25519KeyPair: KeyPair, + using dependencies: Dependencies = Dependencies() + ) throws -> (plaintext: Data, senderX25519PublicKey: String) { /// Ensure the data is at least long enough to have the required components guard - data.count > (dependencies.nonceGenerator24.NonceBytes + 2), - let blindedKeyPair = dependencies.sodium.blindedKeyPair( - serverPublicKey: openGroupPublicKey, - edKeyPair: userEd25519KeyPair, - genericHash: dependencies.genericHash + data.count > (dependencies.crypto.size(.nonce24) + 2), + let blindedKeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, using: dependencies) ) else { throw MessageReceiverError.decryptionFailed } /// Step one: calculate the shared encryption key, receiving from A to B let otherKeyBytes: Bytes = Data(hex: otherBlindedPublicKey.removingIdPrefixIfNeeded()).bytes let kA: Bytes = (isOutgoing ? blindedKeyPair.publicKey : otherKeyBytes) - guard let dec_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey( - secretKey: userEd25519KeyPair.secretKey, - otherBlindedPublicKey: otherKeyBytes, - fromBlindedPublicKey: kA, - toBlindedPublicKey: (isOutgoing ? otherKeyBytes : blindedKeyPair.publicKey), - genericHash: dependencies.genericHash - ) else { - throw MessageReceiverError.decryptionFailed - } + guard + let dec_key: Bytes = try? dependencies.crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: userEd25519KeyPair.secretKey, + otherBlindedPublicKey: otherKeyBytes, + fromBlindedPublicKey: kA, + toBlindedPublicKey: (isOutgoing ? otherKeyBytes : blindedKeyPair.publicKey), + using: dependencies + ) + ) + else { throw MessageReceiverError.decryptionFailed } /// v, ct, nc = data[0], data[1:-24], data[-24:] let version: UInt8 = data.bytes[0] - let ciphertext: Bytes = Bytes(data.bytes[1..<(data.count - dependencies.nonceGenerator24.NonceBytes)]) - let nonce: Bytes = Bytes(data.bytes[(data.count - dependencies.nonceGenerator24.NonceBytes).. dependencies.sign.PublicKeyBytes else { throw MessageReceiverError.decryptionFailed } + guard innerBytes.count > dependencies.crypto.size(.publicKey) else { throw MessageReceiverError.decryptionFailed } /// Split up: the last 32 bytes are the sender's *unblinded* ed25519 key let plaintext: Bytes = Bytes(innerBytes[ - 0...(innerBytes.count - 1 - dependencies.sign.PublicKeyBytes) + 0...(innerBytes.count - 1 - dependencies.crypto.size(.publicKey)) ]) let sender_edpk: Bytes = Bytes(innerBytes[ - (innerBytes.count - dependencies.sign.PublicKeyBytes)...(innerBytes.count - 1) + (innerBytes.count - dependencies.crypto.size(.publicKey))...(innerBytes.count - 1) ]) /// Verify that the inner sender_edpk (A) yields the same outer kA we got with the message - guard let blindingFactor: Bytes = dependencies.sodium.generateBlindingFactor(serverPublicKey: openGroupPublicKey, genericHash: dependencies.genericHash) else { - throw MessageReceiverError.invalidSignature - } - guard let sharedSecret: Bytes = dependencies.sodium.combineKeys(lhsKeyBytes: blindingFactor, rhsKeyBytes: sender_edpk) else { - throw MessageReceiverError.invalidSignature - } - guard kA == sharedSecret else { throw MessageReceiverError.invalidSignature } + guard + let blindingFactor: Bytes = try? dependencies.crypto.perform( + .generateBlindingFactor(serverPublicKey: openGroupPublicKey, using: dependencies) + ), + let sharedSecret: Bytes = try? dependencies.crypto.perform( + .combineKeys(lhsKeyBytes: blindingFactor, rhsKeyBytes: sender_edpk) + ), + kA == sharedSecret + else { throw MessageReceiverError.invalidSignature } /// Get the sender's X25519 public key - guard let senderSessionIdBytes: Bytes = dependencies.sign.toX25519(ed25519PublicKey: sender_edpk) else { - throw MessageReceiverError.decryptionFailed - } + guard + let senderSessionIdBytes: Bytes = try? dependencies.crypto.perform( + .toX25519(ed25519PublicKey: sender_edpk) + ) + else { throw MessageReceiverError.decryptionFailed } return (Data(plaintext), SessionId(.standard, publicKey: senderSessionIdBytes).hexString) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 5eec855eb..e1d1b8be8 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -18,9 +18,9 @@ public enum MessageReceiver { openGroupServerPublicKey: String?, isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> (Message, SNProtoContent, String, SessionThread.Variant) { - let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let isOpenGroupMessage: Bool = (openGroupId != nil) // Decrypt the contents @@ -183,7 +183,7 @@ public enum MessageReceiver { message: Message, serverExpirationTimestamp: TimeInterval?, associatedWithProto proto: SNProtoContent, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws { // Check if the message requires an existing conversation (if it does and the conversation isn't in // the config then the message will be dropped) @@ -198,7 +198,7 @@ public enum MessageReceiver { message: message, threadId: threadId, threadVariant: threadVariant, - dependencies: dependencies + using: dependencies ) switch message { @@ -222,7 +222,8 @@ public enum MessageReceiver { db, threadId: threadId, threadVariant: threadVariant, - message: message + message: message, + using: dependencies ) case let message as DataExtractionNotification: @@ -242,7 +243,11 @@ public enum MessageReceiver { ) case let message as ConfigurationMessage: - try MessageReceiver.handleLegacyConfigurationMessage(db, message: message) + try MessageReceiver.handleLegacyConfigurationMessage( + db, + message: message, + using: dependencies + ) case let message as UnsendRequest: try MessageReceiver.handleUnsendRequest( @@ -264,7 +269,7 @@ public enum MessageReceiver { try MessageReceiver.handleMessageRequestResponse( db, message: message, - dependencies: dependencies + using: dependencies ) case let message as VisibleMessage: @@ -360,7 +365,7 @@ public enum MessageReceiver { message: Message, threadId: String, threadVariant: SessionThread.Variant, - dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws { switch message { case is ReadReceipt: return // No visible artifact created so better to keep for more reliable read states @@ -369,7 +374,7 @@ public enum MessageReceiver { } // Determine the state of the conversation and the validity of the message - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let conversationVisibleInConfig: Bool = SessionUtil.conversationInConfig( db, threadId: threadId, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index f0e139d35..5ca3e6333 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -14,7 +14,8 @@ extension MessageSender { interaction: Interaction, threadId: String, threadVariant: SessionThread.Variant, - isSyncMessage: Bool = false + isSyncMessage: Bool = false, + using dependencies: Dependencies ) throws { // Only 'VisibleMessage' types can be sent via this method guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } @@ -26,7 +27,8 @@ extension MessageSender { threadId: threadId, interactionId: interactionId, to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), - isSyncMessage: isSyncMessage + isSyncMessage: isSyncMessage, + using: dependencies ) } @@ -36,7 +38,8 @@ extension MessageSender { interactionId: Int64?, threadId: String, threadVariant: SessionThread.Variant, - isSyncMessage: Bool = false + isSyncMessage: Bool = false, + using dependencies: Dependencies ) throws { send( db, @@ -44,7 +47,8 @@ extension MessageSender { threadId: threadId, interactionId: interactionId, to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), - isSyncMessage: isSyncMessage + isSyncMessage: isSyncMessage, + using: dependencies ) } @@ -54,7 +58,8 @@ extension MessageSender { threadId: String?, interactionId: Int64?, to destination: Message.Destination, - isSyncMessage: Bool = false + isSyncMessage: Bool = false, + using dependencies: Dependencies ) { // If it's a sync message then we need to make some slight tweaks before sending so use the proper // sync message sending process instead of the standard process @@ -65,12 +70,13 @@ extension MessageSender { destination: destination, threadId: threadId, interactionId: interactionId, - isAlreadySyncMessage: false + isAlreadySyncMessage: false, + using: dependencies ) return } - JobRunner.add( + dependencies.jobRunner.add( db, job: Job( variant: .messageSend, @@ -81,7 +87,9 @@ extension MessageSender { message: message, isSyncMessage: isSyncMessage ) - ) + ), + canStartJob: true, + using: dependencies ) } @@ -91,7 +99,8 @@ extension MessageSender { _ db: Database, interaction: Interaction, threadId: String, - threadVariant: SessionThread.Variant + threadVariant: SessionThread.Variant, + using dependencies: Dependencies ) throws -> PreparedSendData { // Only 'VisibleMessage' types can be sent via this method guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } @@ -104,11 +113,15 @@ extension MessageSender { namespace: try Message.Destination .from(db, threadId: threadId, threadVariant: threadVariant) .defaultNamespace, - interactionId: interactionId + interactionId: interactionId, + using: dependencies ) } - public static func performUploadsIfNeeded(preparedSendData: PreparedSendData) -> AnyPublisher { + public static func performUploadsIfNeeded( + preparedSendData: PreparedSendData, + using dependencies: Dependencies + ) -> AnyPublisher { // We need an interactionId in order for a message to have uploads guard let interactionId: Int64 = preparedSendData.interactionId else { return Just(preparedSendData) @@ -127,7 +140,7 @@ extension MessageSender { } }() - return Storage.shared + return dependencies.storage .readPublisher { db -> (attachments: [Attachment], openGroup: OpenGroup?) in let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment .stateInfo(interactionId: interactionId, state: .uploading) @@ -162,7 +175,8 @@ extension MessageSender { to: ( openGroup.map { Attachment.Destination.openGroup($0) } ?? .fileServer - ) + ), + using: dependencies ) } ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index a9d4dca47..287c2737a 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -10,7 +10,7 @@ extension MessageSender { _ db: Database, plaintext: Data, for recipientHexEncodedX25519PublicKey: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> Data { guard let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw MessageSenderError.noUserED25519KeyPair @@ -19,14 +19,21 @@ extension MessageSender { let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) let verificationData = plaintext + Data(userEd25519KeyPair.publicKey) + recipientX25519PublicKey - guard let signature = dependencies.sign.signature(message: Bytes(verificationData), secretKey: userEd25519KeyPair.secretKey) else { - throw MessageSenderError.signingFailed - } + guard + let signature = try? dependencies.crypto.perform( + .signature(message: Bytes(verificationData), secretKey: userEd25519KeyPair.secretKey) + ) + else { throw MessageSenderError.signingFailed } let plaintextWithMetadata = plaintext + Data(userEd25519KeyPair.publicKey) + Data(signature) - guard let ciphertext = dependencies.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { - throw MessageSenderError.encryptionFailed - } + guard + let ciphertext = try? dependencies.crypto.perform( + .seal( + message: Bytes(plaintextWithMetadata), + recipientPublicKey: Bytes(recipientX25519PublicKey) + ) + ) + else { throw MessageSenderError.encryptionFailed } return Data(ciphertext) } @@ -36,7 +43,7 @@ extension MessageSender { plaintext: Data, for recipientBlindedId: String, openGroupPublicKey: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> Data { guard SessionId.Prefix(from: recipientBlindedId) == .blinded15 || @@ -45,32 +52,37 @@ extension MessageSender { guard let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw MessageSenderError.noUserED25519KeyPair } - guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { - throw MessageSenderError.signingFailed - } + guard + let blindedKeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, using: dependencies) + ) + else { throw MessageSenderError.signingFailed } let recipientBlindedPublicKey = Data(hex: recipientBlindedId.removingIdPrefixIfNeeded()) /// Step one: calculate the shared encryption key, sending from A to B - guard let enc_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey( - secretKey: userEd25519KeyPair.secretKey, - otherBlindedPublicKey: recipientBlindedPublicKey.bytes, - fromBlindedPublicKey: blindedKeyPair.publicKey, - toBlindedPublicKey: recipientBlindedPublicKey.bytes, - genericHash: dependencies.genericHash - ) else { - throw MessageSenderError.signingFailed - } + guard + let enc_key: Bytes = try? dependencies.crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: userEd25519KeyPair.secretKey, + otherBlindedPublicKey: recipientBlindedPublicKey.bytes, + fromBlindedPublicKey: blindedKeyPair.publicKey, + toBlindedPublicKey: recipientBlindedPublicKey.bytes, + using: dependencies + ) + ), + let nonce: Bytes = try? dependencies.crypto.perform(.generateNonce24()) + else { throw MessageSenderError.signingFailed } /// Inner data: msg || A (i.e. the sender's ed25519 master pubkey, *not* kA blinded pubkey) let innerBytes: Bytes = (plaintext.bytes + userEd25519KeyPair.publicKey) /// Encrypt using xchacha20-poly1305 - let nonce: Bytes = dependencies.nonceGenerator24.nonce() - - guard let ciphertext = dependencies.aeadXChaCha20Poly1305Ietf.encrypt(message: innerBytes, secretKey: enc_key, nonce: nonce) else { - throw MessageSenderError.encryptionFailed - } + guard + let ciphertext = try? dependencies.crypto.perform( + .encryptAeadXChaCha20(message: innerBytes, secretKey: enc_key, nonce: nonce, using: dependencies) + ) + else { throw MessageSenderError.encryptionFailed } /// data = b'\x00' + ciphertext + nonce return Data(Bytes(arrayLiteral: 0) + ciphertext + nonce) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index a40c3a426..9b968bdab 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -140,10 +140,10 @@ public final class MessageSender { namespace: SnodeAPI.Namespace?, interactionId: Int64?, isSyncMessage: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies = Dependencies() ) throws -> PreparedSendData { // Common logic for all destinations - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let messageSendTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() let updatedMessage: Message = message @@ -199,7 +199,7 @@ public final class MessageSender { userPublicKey: String, messageSendTimestamp: Int64, isSyncMessage: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> PreparedSendData { message.sender = userPublicKey message.recipient = { @@ -276,7 +276,7 @@ public final class MessageSender { do { switch destination { case .contact(let publicKey): - ciphertext = try encryptWithSessionProtocol(db, plaintext: plaintext, for: publicKey) + ciphertext = try encryptWithSessionProtocol(db, plaintext: plaintext, for: publicKey, using: dependencies) case .closedGroup(let groupPublicKey): guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, threadId: groupPublicKey) else { @@ -286,7 +286,8 @@ public final class MessageSender { ciphertext = try encryptWithSessionProtocol( db, plaintext: plaintext, - for: SessionId(.standard, publicKey: encryptionKeyPair.publicKey.bytes).hexString + for: SessionId(.standard, publicKey: encryptionKeyPair.publicKey.bytes).hexString, + using: dependencies ) case .openGroup, .openGroupInbox: preconditionFailure() @@ -365,7 +366,7 @@ public final class MessageSender { to destination: Message.Destination, interactionId: Int64?, messageSendTimestamp: Int64, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> PreparedSendData { let threadId: String @@ -394,7 +395,7 @@ public final class MessageSender { throw MessageSenderError.invalidMessage } - message.sender = { + message.sender = try { let capabilities: [Capability.Variant] = (try? Capability .select(.variant) .filter(Capability.Columns.openGroupServer == server) @@ -407,9 +408,11 @@ public final class MessageSender { guard capabilities.isEmpty || capabilities.contains(.blind) else { return SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString } - guard let blindedKeyPair: KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { - preconditionFailure() - } + guard + let blindedKeyPair: KeyPair = dependencies.crypto.generate( + .blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, using: dependencies) + ) + else { throw MessageSenderError.signingFailed } return SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString }() @@ -492,7 +495,7 @@ public final class MessageSender { interactionId: Int64?, userPublicKey: String, messageSendTimestamp: Int64, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws -> PreparedSendData { guard case .openGroupInbox(_, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else { throw MessageSenderError.invalidMessage @@ -582,10 +585,10 @@ public final class MessageSender { // MARK: - Sending public static func sendImmediate( - preparedSendData: PreparedSendData, - using dependencies: SMKDependencies = SMKDependencies() + data: PreparedSendData, + using dependencies: Dependencies ) -> AnyPublisher { - guard preparedSendData.shouldSend else { + guard data.shouldSend else { return Just(()) .setFailureType(to: Error.self) .eraseToAnyPublisher() @@ -597,7 +600,7 @@ public final class MessageSender { // // If you see this error then you need to call // `MessageSender.performUploadsIfNeeded(queue:preparedSendData:)` before calling this function - switch preparedSendData.message { + switch data.message { case let visibleMessage as VisibleMessage: let expectedAttachmentUploadCount: Int = ( visibleMessage.attachmentIds.count + @@ -605,17 +608,17 @@ public final class MessageSender { (visibleMessage.quote?.attachmentId != nil ? 1 : 0) ) - guard expectedAttachmentUploadCount == preparedSendData.totalAttachmentsUploaded else { + guard expectedAttachmentUploadCount == data.totalAttachmentsUploaded else { // Make sure to actually handle this as a failure (if we don't then the message // won't go into an error state correctly) - if let message: Message = preparedSendData.message { + if let message: Message = data.message { dependencies.storage.read { db in MessageSender.handleFailedMessageSend( db, message: message, with: .attachmentsNotUploaded, - interactionId: preparedSendData.interactionId, - isSyncMessage: (preparedSendData.isSyncMessage == true), + interactionId: data.interactionId, + isSyncMessage: (data.isSyncMessage == true), using: dependencies ) } @@ -630,10 +633,10 @@ public final class MessageSender { default: break } - switch preparedSendData.destination { - case .contact, .closedGroup: return sendToSnodeDestination(data: preparedSendData, using: dependencies) - case .openGroup: return sendToOpenGroupDestination(data: preparedSendData, using: dependencies) - case .openGroupInbox: return sendToOpenGroupInbox(data: preparedSendData, using: dependencies) + switch data.destination { + case .contact, .closedGroup: return sendToSnodeDestination(data: data, using: dependencies) + case .openGroup: return sendToOpenGroupDestination(data: data, using: dependencies) + case .openGroupInbox: return sendToOpenGroupInbox(data: data, using: dependencies) } } @@ -641,7 +644,7 @@ public final class MessageSender { private static func sendToSnodeDestination( data: PreparedSendData, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> AnyPublisher { guard let message: Message = data.message, @@ -653,14 +656,11 @@ public final class MessageSender { .eraseToAnyPublisher() } - return SnodeAPI - .sendMessage( - snodeMessage, - in: namespace - ) - .flatMap { response -> AnyPublisher in + return dependencies.network + .send(.message(snodeMessage, in: namespace, using: dependencies)) + .flatMap { info, response -> AnyPublisher in let updatedMessage: Message = message - updatedMessage.serverHash = response.1.hash + updatedMessage.serverHash = response.hash let job: Job? = Job( variant: .notifyPushServer, @@ -695,7 +695,7 @@ public final class MessageSender { guard shouldNotify else { return () } - JobRunner.add(db, job: job) + dependencies.jobRunner.add(db, job: job, canStartJob: true, using: dependencies) return () } .flatMap { _ -> AnyPublisher in @@ -726,7 +726,8 @@ public final class MessageSender { deferred: { _, _ in // Always fulfill because the notify PN server job isn't critical. resolver(Result.success(())) - } + }, + using: dependencies ) } } @@ -761,7 +762,7 @@ public final class MessageSender { private static func sendToOpenGroupDestination( data: PreparedSendData, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> AnyPublisher { guard let message: Message = data.message, @@ -829,7 +830,7 @@ public final class MessageSender { private static func sendToOpenGroupInbox( data: PreparedSendData, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> AnyPublisher { guard let message: Message = data.message, @@ -926,7 +927,7 @@ public final class MessageSender { interactionId: Int64?, serverTimestampMs: UInt64? = nil, isSyncMessage: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) throws { // If the message was a reaction then we want to update the reaction instead of the original // interaction (which the 'interactionId' is pointing to @@ -964,13 +965,15 @@ public final class MessageSender { .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent)) // Start the disappearing messages timer if needed - JobRunner.upsert( + dependencies.jobRunner.upsert( db, job: DisappearingMessagesJob.updateNextRunIfNeeded( db, interaction: interaction, startedAtMs: TimeInterval(SnodeAPI.currentOffsetTimestampMs()) - ) + ), + canStartJob: true, + using: dependencies ) } } @@ -995,7 +998,8 @@ public final class MessageSender { destination: destination, threadId: threadId, interactionId: interactionId, - isAlreadySyncMessage: isSyncMessage + isAlreadySyncMessage: isSyncMessage, + using: dependencies ) } @@ -1005,7 +1009,7 @@ public final class MessageSender { with error: MessageSenderError, interactionId: Int64?, isSyncMessage: Bool = false, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> Error { // If the message was a reaction then we don't want to do anything to the original // interaciton (which the 'interactionId' is pointing to @@ -1072,11 +1076,12 @@ public final class MessageSender { destination: Message.Destination, threadId: String?, interactionId: Int64?, - isAlreadySyncMessage: Bool + isAlreadySyncMessage: Bool, + using dependencies: Dependencies ) { // Sync the message if it's not a sync message, wasn't already sent to the current user and // it's a message type which should be synced - let currentUserPublicKey = getUserHexEncodedPublicKey(db) + let currentUserPublicKey = getUserHexEncodedPublicKey(db, using: dependencies) if case .contact(let publicKey) = destination, @@ -1087,7 +1092,7 @@ public final class MessageSender { if let message = message as? VisibleMessage { message.syncTarget = publicKey } if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey } - JobRunner.add( + dependencies.jobRunner.add( db, job: Job( variant: .messageSend, @@ -1098,7 +1103,9 @@ public final class MessageSender { message: message, isSyncMessage: true ) - ) + ), + canStartJob: true, + using: dependencies ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 7f7a798b6..c4c77c7fa 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -23,23 +23,23 @@ public final class ClosedGroupPoller: Poller { // MARK: - Public API - public func start() { + public func start(using dependencies: Dependencies = Dependencies()) { // Fetch all closed groups (excluding any don't contain the current user as a // GroupMemeber as the user is no longer a member of those) - Storage.shared + dependencies.storage .read { db in try ClosedGroup .select(.threadId) .joining( required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db, using: dependencies)) ) .asRequest(of: String.self) .fetchAll(db) } .defaulting(to: []) .forEach { [weak self] publicKey in - self?.startIfNeeded(for: publicKey) + self?.startIfNeeded(for: publicKey, using: dependencies) } } @@ -49,7 +49,7 @@ public final class ClosedGroupPoller: Poller { return "closed group with public key: \(publicKey)" } - override func nextPollDelay(for publicKey: String) -> TimeInterval { + override func nextPollDelay(for publicKey: String, using dependencies: Dependencies) -> TimeInterval { // Get the received date of the last message in the thread. If we don't have // any messages yet, pick some reasonable fake time interval to use instead let lastMessageDate: Date = Storage.shared @@ -68,7 +68,7 @@ public final class ClosedGroupPoller: Poller { } .defaulting(to: Date().addingTimeInterval(-5 * 60)) - let timeSinceLastMessage: TimeInterval = Date().timeIntervalSince(lastMessageDate) + let timeSinceLastMessage: TimeInterval = dependencies.dateNow.timeIntervalSince(lastMessageDate) let minPollInterval: Double = ClosedGroupPoller.minPollInterval let limit: Double = (12 * 60 * 60) let a: TimeInterval = ((ClosedGroupPoller.maxPollInterval - minPollInterval) / limit) @@ -78,11 +78,7 @@ public final class ClosedGroupPoller: Poller { return nextPollInterval } - override func handlePollError( - _ error: Error, - for publicKey: String, - using dependencies: SMKDependencies = SMKDependencies() - ) -> Bool { + override func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> Bool { SNLog("Polling failed for closed group with public key: \(publicKey) due to error: \(error).") return true } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index 3936baf4f..3e62ad28c 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -33,13 +33,13 @@ public final class CurrentUserPoller: Poller { // MARK: - Convenience Functions - public func start() { - let publicKey: String = getUserHexEncodedPublicKey() + public func start(using dependencies: Dependencies = Dependencies()) { + let publicKey: String = getUserHexEncodedPublicKey(using: dependencies) guard isPolling.wrappedValue[publicKey] != true else { return } SNLog("Started polling.") - super.startIfNeeded(for: publicKey) + super.startIfNeeded(for: publicKey, using: dependencies) } public func stop() { @@ -53,7 +53,7 @@ public final class CurrentUserPoller: Poller { return "Main Poller" } - override func nextPollDelay(for publicKey: String) -> TimeInterval { + override func nextPollDelay(for publicKey: String, using dependencies: Dependencies) -> TimeInterval { let failureCount: TimeInterval = TimeInterval(failureCount.wrappedValue[publicKey] ?? 0) // If there have been no failures then just use the 'minPollInterval' @@ -65,11 +65,7 @@ public final class CurrentUserPoller: Poller { return min(maxRetryInterval, nextDelay) } - override func handlePollError( - _ error: Error, - for publicKey: String, - using dependencies: SMKDependencies = SMKDependencies() - ) -> Bool { + override func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> Bool { if UserDefaults.sharedLokiProject?[.isMainAppActive] != true { // Do nothing when an error gets throws right after returning from the background (happens frequently) } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 48cb16f64..0192286ea 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -35,7 +35,7 @@ extension OpenGroupAPI { self.server = server } - public func startIfNeeded(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { + public func startIfNeeded(using dependencies: Dependencies) { guard !hasStarted else { return } hasStarted = true @@ -49,20 +49,15 @@ extension OpenGroupAPI { // MARK: - Polling - private func pollRecursively( - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies( - subscribeQueue: Threading.pollerQueue, - receiveQueue: OpenGroupAPI.workQueue - ) - ) { + private func pollRecursively(using dependencies: Dependencies) { guard hasStarted else { return } let server: String = self.server - let lastPollStart: TimeInterval = Date().timeIntervalSince1970 + let lastPollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 poll(using: dependencies) - .subscribe(on: dependencies.subscribeQueue) - .receive(on: dependencies.receiveQueue) + .subscribe(on: Threading.pollerQueue, using: dependencies) + .receive(on: OpenGroupAPI.workQueue, using: dependencies) .sinkUntilComplete( receiveCompletion: { [weak self] _ in let minPollFailureCount: Int64 = dependencies.storage @@ -76,7 +71,7 @@ extension OpenGroupAPI { .defaulting(to: 0) // Calculate the remaining poll delay - let currentTime: TimeInterval = Date().timeIntervalSince1970 + let currentTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 let nextPollInterval: TimeInterval = Poller.getInterval( for: TimeInterval(minPollFailureCount), minInterval: Poller.minPollInterval, @@ -86,12 +81,12 @@ extension OpenGroupAPI { // Schedule the next poll guard remainingInterval > 0 else { - return dependencies.subscribeQueue.async { + return Threading.pollerQueue.async(using: dependencies) { self?.pollRecursively(using: dependencies) } } - dependencies.subscribeQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default) { + Threading.pollerQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default, using: dependencies) { self?.pollRecursively(using: dependencies) } } @@ -99,7 +94,7 @@ extension OpenGroupAPI { } public func poll( - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { return poll( calledFromBackgroundPoller: false, @@ -112,7 +107,7 @@ extension OpenGroupAPI { calledFromBackgroundPoller: Bool, isBackgroundPollerValid: @escaping (() -> Bool) = { true }, isPostCapabilitiesRetry: Bool, - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard !self.isPolling else { return Just(()) @@ -122,10 +117,12 @@ extension OpenGroupAPI { self.isPolling = true let server: String = self.server - let hasPerformedInitialPoll: Bool = (dependencies.cache.hasPerformedInitialPoll[server] == true) + let hasPerformedInitialPoll: Bool = (dependencies.caches[.openGroupManager].hasPerformedInitialPoll[server] == true) let timeSinceLastPoll: TimeInterval = ( - dependencies.cache.timeSinceLastPoll[server] ?? - dependencies.mutableCache.mutate { $0.getTimeSinceLastOpen(using: dependencies) } + dependencies.caches[.openGroupManager].timeSinceLastPoll[server] ?? + dependencies.caches.mutate(cache: .openGroupManager) { cache in + cache.getTimeSinceLastOpen(using: dependencies) + } ) return dependencies.storage @@ -170,10 +167,11 @@ extension OpenGroupAPI { using: dependencies ) - dependencies.mutableCache.mutate { cache in + + dependencies.caches.mutate(cache: .openGroupManager) { cache in cache.hasPerformedInitialPoll[server] = true - cache.timeSinceLastPoll[server] = Date().timeIntervalSince1970 - UserDefaults.standard[.lastOpen] = Date() + cache.timeSinceLastPoll[server] = dependencies.dateNow.timeIntervalSince1970 + dependencies.standardUserDefaults[.lastOpen] = dependencies.dateNow } SNLog("Open group polling finished for \(server).") @@ -303,7 +301,7 @@ extension OpenGroupAPI { isBackgroundPollerValid: @escaping (() -> Bool) = { true }, isPostCapabilitiesRetry: Bool, error: Error, - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { /// We want to custom handle a '400' error code due to not having blinded auth as it likely means that we join the /// OpenGroup before blinding was enabled and need to update it's capabilities @@ -376,7 +374,7 @@ extension OpenGroupAPI { info: ResponseInfoType, response: BatchResponse, failureCount: Int64, - using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + using dependencies: Dependencies ) { let server: String = self.server let validResponses: [OpenGroupAPI.Endpoint: Decodable] = response.data @@ -550,7 +548,7 @@ extension OpenGroupAPI { publicKey: nil, for: roomToken, on: server, - dependencies: dependencies + using: dependencies ) case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): @@ -564,7 +562,7 @@ extension OpenGroupAPI { messages: responseBody.compactMap { $0.value }, for: roomToken, on: server, - dependencies: dependencies + using: dependencies ) case .inbox, .inboxSince, .outbox, .outboxSince: @@ -587,7 +585,7 @@ extension OpenGroupAPI { messages: messages, fromOutbox: fromOutbox, on: server, - dependencies: dependencies + using: dependencies ) default: break // No custom handling needed diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index bd984c6ea..b4c89e410 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -53,18 +53,18 @@ public class Poller { } /// Calculate the delay which should occur before the next poll - internal func nextPollDelay(for publicKey: String) -> TimeInterval { + internal func nextPollDelay(for publicKey: String, using dependencies: Dependencies) -> TimeInterval { preconditionFailure("abstract class - override in subclass") } /// Perform and logic which should occur when the poll errors, will stop polling if `false` is returned - internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: SMKDependencies) -> Bool { + internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> Bool { preconditionFailure("abstract class - override in subclass") } // MARK: - Private API - internal func startIfNeeded(for publicKey: String) { + internal func startIfNeeded(for publicKey: String, using dependencies: Dependencies) { // Run on the 'pollerQueue' to ensure any 'Atomic' access doesn't block the main thread // on startup Threading.pollerQueue.async { [weak self] in @@ -74,13 +74,13 @@ public class Poller { // and the timer is not created, if we mark the group as is polling // after setUpPolling. So the poller may not work, thus misses messages self?.isPolling.mutate { $0[publicKey] = true } - self?.pollRecursively(for: publicKey) + self?.pollRecursively(for: publicKey, using: dependencies) } } internal func getSnodeForPolling( for publicKey: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) -> AnyPublisher { // If we don't want to poll a snode multiple times then just grab a random one from the swarm guard maxNodePollCount > 0 else { @@ -135,14 +135,14 @@ public class Poller { private func pollRecursively( for publicKey: String, - using dependencies: SMKDependencies = SMKDependencies() + using dependencies: Dependencies ) { guard isPolling.wrappedValue[publicKey] == true else { return } let namespaces: [SnodeAPI.Namespace] = self.namespaces - let lastPollStart: TimeInterval = Date().timeIntervalSince1970 - let lastPollInterval: TimeInterval = nextPollDelay(for: publicKey) - let getSnodePublisher: AnyPublisher = getSnodeForPolling(for: publicKey) + let lastPollStart: TimeInterval = dependencies.dateNow.timeIntervalSince1970 + let lastPollInterval: TimeInterval = nextPollDelay(for: publicKey, using: dependencies) + let getSnodePublisher: AnyPublisher = getSnodeForPolling(for: publicKey, using: dependencies) // Store the publisher intp the cancellables dictionary cancellables.mutate { [weak self] cancellables in @@ -156,8 +156,8 @@ public class Poller { using: dependencies ) } - .subscribe(on: dependencies.subscribeQueue) - .receive(on: dependencies.receiveQueue) + .subscribe(on: Threading.pollerQueue, using: dependencies) + .receive(on: Threading.pollerQueue, using: dependencies) .sink( receiveCompletion: { result in switch result { @@ -174,21 +174,21 @@ public class Poller { self?.incrementPollCount(publicKey: publicKey) // Calculate the remaining poll delay - let currentTime: TimeInterval = Date().timeIntervalSince1970 + let currentTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 let nextPollInterval: TimeInterval = ( - self?.nextPollDelay(for: publicKey) ?? + self?.nextPollDelay(for: publicKey, using: dependencies) ?? lastPollInterval ) let remainingInterval: TimeInterval = max(0, nextPollInterval - (currentTime - lastPollStart)) // Schedule the next poll guard remainingInterval > 0 else { - return dependencies.subscribeQueue.async { + return Threading.pollerQueue.async(using: dependencies) { self?.pollRecursively(for: publicKey, using: dependencies) } } - dependencies.subscribeQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default) { + Threading.pollerQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default, using: dependencies) { self?.pollRecursively(for: publicKey, using: dependencies) } }, @@ -209,10 +209,7 @@ public class Poller { calledFromBackgroundPoller: Bool = false, isBackgroundPollValid: @escaping (() -> Bool) = { true }, poller: Poller? = nil, - using dependencies: SMKDependencies = SMKDependencies( - subscribeQueue: Threading.pollerQueue, - receiveQueue: Threading.pollerQueue - ) + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[Message], Error> { // If the polling has been cancelled then don't continue guard @@ -276,7 +273,7 @@ public class Poller { var standardMessageJobsToRun: [Job] = [] var pollerLogOutput: String = "\(pollerName) failed to process any messages" - Storage.shared.write { db in + dependencies.storage.write { db in let allProcessedMessages: [ProcessedMessage] = allMessages .compactMap { message -> ProcessedMessage? in do { @@ -338,7 +335,7 @@ public class Poller { db, job: jobToRun, canStartJob: !calledFromBackgroundPoller, - dependencies: dependencies + using: dependencies ) return updatedJob?.id @@ -372,7 +369,7 @@ public class Poller { db, job: jobToRun, canStartJob: !calledFromBackgroundPoller, - dependencies: dependencies + using: dependencies ) // Create the dependency between the jobs @@ -429,10 +426,11 @@ public class Poller { // Note: In the background we just want jobs to fail silently ConfigMessageReceiveJob.run( job, - queue: dependencies.receiveQueue, + queue: Threading.pollerQueue, success: { _, _, _ in resolver(Result.success(())) }, failure: { _, _, _, _ in resolver(Result.success(())) }, - deferred: { _, _ in resolver(Result.success(())) } + deferred: { _, _ in resolver(Result.success(())) }, + using: dependencies ) } } @@ -449,10 +447,11 @@ public class Poller { // Note: In the background we just want jobs to fail silently MessageReceiveJob.run( job, - queue: dependencies.receiveQueue, + queue: Threading.pollerQueue, success: { _, _, _ in resolver(Result.success(())) }, failure: { _, _, _, _ in resolver(Result.success(())) }, - deferred: { _, _ in resolver(Result.success(())) } + deferred: { _, _ in resolver(Result.success(())) }, + using: dependencies ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index b8c4d3b49..e10970955 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -54,11 +54,11 @@ public class TypingIndicators { self.timestampMs = (timestampMs ?? SnodeAPI.currentOffsetTimestampMs()) } - fileprivate func start(_ db: Database) { + fileprivate func start(_ db: Database, using dependencies: Dependencies = Dependencies()) { // Start the typing indicator switch direction { case .outgoing: - scheduleRefreshCallback(db, shouldSend: (refreshTimer == nil)) + scheduleRefreshCallback(db, shouldSend: (refreshTimer == nil), using: dependencies) case .incoming: try? ThreadTypingIndicator( @@ -72,7 +72,7 @@ public class TypingIndicators { refreshTimeout() } - fileprivate func stop(_ db: Database) { + fileprivate func stop(_ db: Database, using dependencies: Dependencies = Dependencies()) { self.refreshTimer?.invalidate() self.refreshTimer = nil self.stopTimer?.invalidate() @@ -85,7 +85,8 @@ public class TypingIndicators { message: TypingIndicator(kind: .stopped), interactionId: nil, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) case .incoming: @@ -111,14 +112,19 @@ public class TypingIndicators { } } - private func scheduleRefreshCallback(_ db: Database, shouldSend: Bool = true) { + private func scheduleRefreshCallback( + _ db: Database, + shouldSend: Bool = true, + using dependencies: Dependencies + ) { if shouldSend { try? MessageSender.send( db, message: TypingIndicator(kind: .started), interactionId: nil, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } @@ -127,8 +133,8 @@ public class TypingIndicators { withTimeInterval: 10, repeats: false ) { [weak self] _ in - Storage.shared.writeAsync { db in - self?.scheduleRefreshCallback(db) + dependencies.storage.writeAsync { db in + self?.scheduleRefreshCallback(db, using: dependencies) } } } diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift index a10e617be..114914f9d 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift @@ -29,7 +29,8 @@ internal extension SessionUtil { _ db: Database, in conf: UnsafeMutablePointer?, mergeNeedsDump: Bool, - latestConfigSentTimestampMs: Int64 + latestConfigSentTimestampMs: Int64, + using dependencies: Dependencies = Dependencies() ) throws { guard mergeNeedsDump else { return } guard conf != nil else { throw SessionUtilError.nilConfigObject } @@ -236,7 +237,8 @@ internal extension SessionUtil { admins: updatedAdmins.map { $0.profileId }, expirationTimer: UInt32(group.disappearingConfig?.durationSeconds ?? 0), formationTimestampMs: UInt64((group.joinedAt.map { $0 * 1000 } ?? latestConfigSentTimestampMs)), - calledFromConfigHandling: true + calledFromConfigHandling: true, + using: dependencies ) } else { diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift index 2bbe3cc4d..ed522930e 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -18,7 +18,8 @@ internal extension SessionUtil { _ db: Database, in conf: UnsafeMutablePointer?, mergeNeedsDump: Bool, - latestConfigSentTimestampMs: Int64 + latestConfigSentTimestampMs: Int64, + using dependencies: Dependencies = Dependencies() ) throws { typealias ProfileData = (profileName: String, profilePictureUrl: String?, profilePictureKey: Data?) @@ -51,7 +52,8 @@ internal extension SessionUtil { ) }(), sentTimestamp: (TimeInterval(latestConfigSentTimestampMs) / 1000), - calledFromConfigHandling: true + calledFromConfigHandling: true, + using: dependencies ) // Update the 'Note to Self' visibility and priority diff --git a/SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift new file mode 100644 index 000000000..d2a974ef5 --- /dev/null +++ b/SessionMessagingKit/Utilities/Crypto+SessionMessagingKit.swift @@ -0,0 +1,144 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import Clibsodium +import Curve25519Kit +import SessionUtilitiesKit + +// MARK: - Generic Hash + +public extension Crypto.Action { + static func hash(message: Bytes, key: Bytes?) -> Crypto.Action { + return Crypto.Action(id: "hash", args: [message, key]) { + Sodium().genericHash.hash(message: message, key: key) + } + } + + static func hash(message: Bytes, outputLength: Int) -> Crypto.Action { + return Crypto.Action(id: "hashOutputLength", args: [message, outputLength]) { + Sodium().genericHash.hash(message: message, outputLength: outputLength) + } + } + + static func hashSaltPersonal( + message: Bytes, + outputLength: Int, + key: Bytes? = nil, + salt: Bytes, + personal: Bytes + ) -> Crypto.Action { + return Crypto.Action( + id: "hashSaltPersonal", + args: [message, outputLength, key, salt, personal] + ) { + var output: [UInt8] = [UInt8](repeating: 0, count: outputLength) + + let result = crypto_generichash_blake2b_salt_personal( + &output, + outputLength, + message, + UInt64(message.count), + key, + (key?.count ?? 0), + salt, + personal + ) + + guard result == 0 else { return nil } + + return output + } + } +} + +// MARK: - Sign + +public extension Crypto.Action { + static func toX25519(ed25519PublicKey: Bytes) -> Crypto.Action { + return Crypto.Action(id: "toX25519", args: [ed25519PublicKey]) { + Sodium().sign.toX25519(ed25519PublicKey: ed25519PublicKey) + } + } + + static func toX25519(ed25519SecretKey: Bytes) -> Crypto.Action { + return Crypto.Action(id: "toX25519", args: [ed25519SecretKey]) { + Sodium().sign.toX25519(ed25519SecretKey: ed25519SecretKey) + } + } + + static func signature(message: Bytes, secretKey: Bytes) -> Crypto.Action { + return Crypto.Action(id: "signature", args: [message, secretKey]) { + Sodium().sign.signature(message: message, secretKey: secretKey) + } + } +} + +public extension Crypto.Verification { + static func signature(message: Bytes, publicKey: Bytes, signature: Bytes) -> Crypto.Verification { + return Crypto.Verification(id: "signature", args: [message, publicKey, signature]) { + Sodium().sign.verify(message: message, publicKey: publicKey, signature: signature) + } + } +} + +// MARK: - Box + +public extension Crypto.Size { + static let signature: Crypto.Size = Crypto.Size(id: "signature") { Sodium().sign.Bytes } + static let publicKey: Crypto.Size = Crypto.Size(id: "publicKey") { Sodium().sign.PublicKeyBytes } +} + +public extension Crypto.Action { + static func seal(message: Bytes, recipientPublicKey: Bytes) -> Crypto.Action { + return Crypto.Action(id: "seal", args: [message, recipientPublicKey]) { + Sodium().box.seal(message: message, recipientPublicKey: recipientPublicKey) + } + } + + static func open(anonymousCipherText: Bytes, recipientPublicKey: Bytes, recipientSecretKey: Bytes) -> Crypto.Action { + return Crypto.Action( + id: "open", + args: [anonymousCipherText, recipientPublicKey, recipientSecretKey] + ) { + Sodium().box.open( + anonymousCipherText: anonymousCipherText, + recipientPublicKey: recipientPublicKey, + recipientSecretKey: recipientSecretKey + ) + } + } +} + +// MARK: - Ed25519 + +public extension Crypto.Action { + static func signEd25519(data: Bytes, keyPair: KeyPair) -> Crypto.Action { + return Crypto.Action(id: "signEd25519", args: [data, keyPair]) { + let ecKeyPair: ECKeyPair = try ECKeyPair( + publicKeyData: Data(keyPair.publicKey), + privateKeyData: Data(keyPair.secretKey) + ) + + return try Ed25519.sign(Data(data), with: ecKeyPair).bytes + } + } +} + +public extension Crypto.Verification { + static func signatureEd25519(_ signature: Data, publicKey: Data, data: Data) -> Crypto.Verification { + return Crypto.Verification(id: "signatureEd25519", args: [signature, publicKey, data]) { + return ((try? Ed25519.verifySignature(signature, publicKey: publicKey, data: data)) == true) + } + } +} + +public extension Crypto.KeyPairType { + static func x25519KeyPair() -> Crypto.KeyPairType { + return Crypto.KeyPairType(id: "x25519KeyPair") { + let keyPair: ECKeyPair = Curve25519.generateKeyPair() + + return KeyPair(publicKey: Array(keyPair.publicKey), secretKey: Array(keyPair.privateKey)) + } + } +} diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 5ffc3d937..7266b30f6 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -286,9 +286,10 @@ public struct ProfileManager { profileName: String, avatarUpdate: AvatarUpdate = .none, success: ((Database) throws -> ())? = nil, - failure: ((ProfileManagerError) -> ())? = nil + failure: ((ProfileManagerError) -> ())? = nil, + using dependencies: Dependencies = Dependencies() ) { - let userPublicKey: String = getUserHexEncodedPublicKey() + let userPublicKey: String = getUserHexEncodedPublicKey(using: dependencies) let isRemovingAvatar: Bool = { switch avatarUpdate { case .remove: return true @@ -298,7 +299,7 @@ public struct ProfileManager { switch avatarUpdate { case .none, .remove, .updateTo: - Storage.shared.writeAsync { db in + dependencies.storage.writeAsync { db in if isRemovingAvatar { let existingProfileUrl: String? = try Profile .filter(id: userPublicKey) @@ -327,7 +328,8 @@ public struct ProfileManager { publicKey: userPublicKey, name: profileName, avatarUpdate: avatarUpdate, - sentTimestamp: Date().timeIntervalSince1970 + sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + using: dependencies ) SNLog("Successfully updated service with profile.") @@ -345,7 +347,8 @@ public struct ProfileManager { publicKey: userPublicKey, name: profileName, avatarUpdate: .updateTo(url: downloadUrl, key: newProfileKey, fileName: fileName), - sentTimestamp: Date().timeIntervalSince1970 + sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + using: dependencies ) SNLog("Successfully updated service with profile.") @@ -498,9 +501,9 @@ public struct ProfileManager { avatarUpdate: AvatarUpdate, sentTimestamp: TimeInterval, calledFromConfigHandling: Bool = false, - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) throws { - let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies)) + let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, using: dependencies)) let profile: Profile = Profile.fetchOrCreate(db, id: publicKey) var profileChanges: [ConfigColumnAssignment] = [] @@ -604,7 +607,7 @@ public struct ProfileManager { let targetProfile: Profile = Profile.fetchOrCreate(db, id: publicKey) // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this - JobRunner.afterBlockingQueue { + dependencies.jobRunner.afterBlockingQueue { ProfileManager.downloadAvatar(for: targetProfile) } } diff --git a/SessionMessagingKit/Utilities/SessionCrypto.swift b/SessionMessagingKit/Utilities/SessionCrypto.swift deleted file mode 100644 index e785ef186..000000000 --- a/SessionMessagingKit/Utilities/SessionCrypto.swift +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import CryptoKit -import Clibsodium -import Sodium -import Curve25519Kit -import SessionUtilitiesKit - -/// These extenion methods are used to generate a sign "blinded" messages -/// -/// According to the Swift engineers the only situation when `UnsafeRawBufferPointer.baseAddress` is nil is when it's an -/// empty collection; as such our guard cases wihch return `-1` when unwrapping this value should never be hit and we can ignore -/// them as possible results. -/// -/// For more information see: -/// https://forums.swift.org/t/when-is-unsafemutablebufferpointer-baseaddress-nil/32136/5 -/// https://github.com/apple/swift-evolution/blob/master/proposals/0055-optional-unsafe-pointers.md#unsafebufferpointer -extension Sodium { - private static let scalarLength: Int = Int(crypto_core_ed25519_scalarbytes()) // 32 - private static let noClampLength: Int = Int(Sodium.lib_crypto_scalarmult_ed25519_bytes()) // 32 - private static let scalarMultLength: Int = Int(crypto_scalarmult_bytes()) // 32 - private static let publicKeyLength: Int = Int(crypto_scalarmult_bytes()) // 32 - private static let secretKeyLength: Int = Int(crypto_sign_secretkeybytes()) // 64 - - /// 64-byte blake2b hash then reduce to get the blinding factor - public func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? { - /// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) - let serverPubKeyData: Data = Data(hex: serverPublicKey) - - guard !serverPubKeyData.isEmpty, let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else { - return nil - } - - /// Reduce the server public key into an ed25519 scalar (`k`) - let kPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - - _ = serverPublicKeyHashBytes.withUnsafeBytes { (serverPublicKeyHashPtr: UnsafeRawBufferPointer) -> Int32 in - guard let serverPublicKeyHashBaseAddress: UnsafePointer = serverPublicKeyHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_reduce(kPtr, serverPublicKeyHashBaseAddress) - return 0 - } - - return Data(bytes: kPtr, count: Sodium.scalarLength).bytes - } - - /// Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to - /// convert to an *x* secret key, which seems wrong--but isn't because converted keys use the - /// same secret scalar secret (and so this is just the most convenient way to get 'a' out of - /// a sodium Ed25519 secret key) - func generatePrivateKeyScalar(secretKey: Bytes) -> Bytes { - /// a = s.to_curve25519_private_key().encode() - let aPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarMultLength) - - /// Looks like the `crypto_sign_ed25519_sk_to_curve25519` function can't actually fail so no need to verify the result - /// See: https://github.com/jedisct1/libsodium/blob/master/src/libsodium/crypto_sign/ed25519/ref10/keypair.c#L70 - _ = secretKey.withUnsafeBytes { (secretKeyPtr: UnsafeRawBufferPointer) -> Int32 in - guard let secretKeyBaseAddress: UnsafePointer = secretKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - return crypto_sign_ed25519_sk_to_curve25519(aPtr, secretKeyBaseAddress) - } - - return Data(bytes: aPtr, count: Sodium.scalarMultLength).bytes - } - - /// Constructs a "blinded" key pair (`ka, kA`) based on an open group server `publicKey` and an ed25519 `keyPair` - public func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair, genericHash: GenericHashType) -> KeyPair? { - guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else { - return nil - } - guard let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey, genericHash: genericHash) else { - return nil - } - let aBytes: Bytes = generatePrivateKeyScalar(secretKey: edKeyPair.secretKey) - - /// Generate the blinded key pair `ka`, `kA` - let kaPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.secretKeyLength) - let kAPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.publicKeyLength) - - _ = aBytes.withUnsafeBytes { (aPtr: UnsafeRawBufferPointer) -> Int32 in - return kBytes.withUnsafeBytes { (kPtr: UnsafeRawBufferPointer) -> Int32 in - guard let kBaseAddress: UnsafePointer = kPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - guard let aBaseAddress: UnsafePointer = aPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress) - return 0 - } - } - - guard crypto_scalarmult_ed25519_base_noclamp(kAPtr, kaPtr) == 0 else { return nil } - - return KeyPair( - publicKey: Data(bytes: kAPtr, count: Sodium.publicKeyLength).bytes, - secretKey: Data(bytes: kaPtr, count: Sodium.secretKeyLength).bytes - ) - } - - /// Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with one tweak to the - /// construction: we add kA into the hashed value that yields r so that we have domain separation for different blinded - /// pubkeys (this doesn't affect verification at all) - public func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { - /// H_rh = sha512(s.encode()).digest()[32:] - let H_rh: Bytes = Bytes(SHA512.hash(data: secretKey).suffix(32)) - - /// r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts)) - let combinedHashBytes: Bytes = SHA512.hash(data: H_rh + kA + message).bytes - let rPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - - _ = combinedHashBytes.withUnsafeBytes { (combinedHashPtr: UnsafeRawBufferPointer) -> Int32 in - guard let combinedHashBaseAddress: UnsafePointer = combinedHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_reduce(rPtr, combinedHashBaseAddress) - return 0 - } - - /// sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) - let sig_RPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) - guard crypto_scalarmult_ed25519_base_noclamp(sig_RPtr, rPtr) == 0 else { return nil } - - /// HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts)) - let sig_RBytes: Bytes = Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes - let HRAMHashBytes: Bytes = SHA512.hash(data: sig_RBytes + kA + message).bytes - let HRAMPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - - _ = HRAMHashBytes.withUnsafeBytes { (HRAMHashPtr: UnsafeRawBufferPointer) -> Int32 in - guard let HRAMHashBaseAddress: UnsafePointer = HRAMHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_reduce(HRAMPtr, HRAMHashBaseAddress) - return 0 - } - - /// sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) - let sig_sMulPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - let sig_sPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) - - _ = ka.withUnsafeBytes { (kaPtr: UnsafeRawBufferPointer) -> Int32 in - guard let kaBaseAddress: UnsafePointer = kaPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - Sodium.lib_crypto_core_ed25519_scalar_mul(sig_sMulPtr, HRAMPtr, kaBaseAddress) - Sodium.lib_crypto_core_ed25519_scalar_add(sig_sPtr, rPtr, sig_sMulPtr) - return 0 - } - - /// full_sig = sig_R + sig_s - return (Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes + Data(bytes: sig_sPtr, count: Sodium.scalarLength).bytes) - } - - /// Combines two keys (`kA`) - public func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { - let combinedPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.noClampLength) - - let result = rhsKeyBytes.withUnsafeBytes { (rhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in - return lhsKeyBytes.withUnsafeBytes { (lhsKeyBytesPtr: UnsafeRawBufferPointer) -> Int32 in - guard let lhsKeyBytesBaseAddress: UnsafePointer = lhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - guard let rhsKeyBytesBaseAddress: UnsafePointer = rhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return -1 // Impossible case (refer to comments at top of extension) - } - - return Sodium.lib_crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress) - } - } - - /// Ensure the above worked - guard result == 0 else { return nil } - - return Data(bytes: combinedPtr, count: Sodium.noClampLength).bytes - } - - /// Calculate a shared secret for a message from A to B: - /// - /// BLAKE2b(a kB || kA || kB) - /// - /// The receiver can calulate the same value via: - /// - /// BLAKE2b(b kA || kA || kB) - public func sharedBlindedEncryptionKey(secretKey: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { - let aBytes: Bytes = generatePrivateKeyScalar(secretKey: secretKey) - - guard let combinedKeyBytes: Bytes = combineKeys(lhsKeyBytes: aBytes, rhsKeyBytes: otherBlindedPublicKey) else { - return nil - } - - return genericHash.hash(message: (combinedKeyBytes + kA + kB), outputLength: 32) - } - - /// This method should be used to check if a users standard sessionId matches a blinded one - public func sessionId(_ standardSessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String, genericHash: GenericHashType) -> Bool { - // Only support generating blinded keys for standard session ids - guard - let sessionId: SessionId = SessionId(from: standardSessionId), - sessionId.prefix == .standard, - let blindedId: SessionId = SessionId(from: blindedSessionId), - ( - blindedId.prefix == .blinded15 || - blindedId.prefix == .blinded25 - ), - let kBytes: Bytes = generateBlindingFactor(serverPublicKey: serverPublicKey, genericHash: genericHash) - else { return false } - - /// From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is the positive (which is what - /// Signal's XEd25519 conversion always uses) - /// - /// Note: The below method is code we have exposed from the `curve25519_verify` method within the Curve25519 library - /// rather than custom code we have written - guard let xEd25519Key: Data = try? Ed25519.publicKey(from: Data(hex: sessionId.publicKey)) else { return false } - - /// Blind the positive public key - guard let pk1: Bytes = combineKeys(lhsKeyBytes: kBytes, rhsKeyBytes: xEd25519Key.bytes) else { return false } - - /// For the negative, what we're going to get out of the above is simply the negative of pk1, so flip the sign bit to get pk2 - /// pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000]) - let pk2: Bytes = (pk1[0..<31] + [(pk1[31] ^ 0b1000_0000)]) - - return ( - SessionId(.blinded15, publicKey: pk1).publicKey == blindedId.publicKey || - SessionId(.blinded15, publicKey: pk2).publicKey == blindedId.publicKey - ) - } -} - -extension GenericHash { - public func hashSaltPersonal( - message: Bytes, - outputLength: Int, - key: Bytes? = nil, - salt: Bytes, - personal: Bytes - ) -> Bytes? { - var output: [UInt8] = [UInt8](repeating: 0, count: outputLength) - - let result = crypto_generichash_blake2b_salt_personal( - &output, - outputLength, - message, - UInt64(message.count), - key, - (key?.count ?? 0), - salt, - personal - ) - - guard result == 0 else { return nil } - - return output - } -} - -extension AeadXChaCha20Poly1305IetfType { - /// This method is the same as the standard AeadXChaCha20Poly1305IetfType `encrypt` method except it allows the - /// specification of a nonce which allows for deterministic behaviour with unit testing - public func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes? = nil) -> Bytes? { - guard secretKey.count == KeyBytes else { return nil } - - var authenticatedCipherText = Bytes(repeating: 0, count: message.count + ABytes) - var authenticatedCipherTextLen: UInt64 = 0 - - let result = crypto_aead_xchacha20poly1305_ietf_encrypt( - &authenticatedCipherText, &authenticatedCipherTextLen, - message, UInt64(message.count), - additionalData, UInt64(additionalData?.count ?? 0), - nil, nonce, secretKey - ) - - guard result == 0 else { return nil } - - return authenticatedCipherText - } -} diff --git a/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift index f8b824165..3de0762cf 100644 --- a/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift @@ -15,8 +15,8 @@ class MessageSendJobSpec: QuickSpec { override func spec() { var job: Job! var interaction: Interaction! - var attachment1: Attachment! - var interactionAttachment1: InteractionAttachment! + var attachment: Attachment! + var interactionAttachment: InteractionAttachment! var mockStorage: Storage! var mockJobRunner: MockJobRunner! var dependencies: Dependencies! @@ -24,8 +24,10 @@ class MessageSendJobSpec: QuickSpec { // MARK: - JobRunner describe("a MessageSendJob") { + // MARK: - Configuration + beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, @@ -36,9 +38,9 @@ class MessageSendJobSpec: QuickSpec { dependencies = Dependencies( storage: mockStorage, jobRunner: mockJobRunner, - date: Date(timeIntervalSince1970: 1234567890) + dateNow: Date(timeIntervalSince1970: 1234567890) ) - attachment1 = Attachment( + attachment = Attachment( id: "200", variant: .standard, state: .failedDownload, @@ -60,7 +62,7 @@ class MessageSendJobSpec: QuickSpec { } .thenReturn([:]) mockJobRunner - .when { $0.insert(any(), job: any(), before: any(), dependencies: dependencies) } + .when { $0.insert(any(), job: any(), before: any()) } .then { args in let db: Database = args[0] as! Database var job: Job = args[1] as! Job @@ -77,6 +79,7 @@ class MessageSendJobSpec: QuickSpec { dependencies = nil } + // MARK: - fails when not given any details it("fails when not given any details") { job = Job(variant: .messageSend) @@ -92,14 +95,15 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } - it("fails when not given incorrect details") { + // MARK: - fails when given incorrect details + it("fails when given incorrect details") { job = Job( variant: .messageSend, details: MessageReceiveJob.Details(messages: [], calledFromBackgroundPoller: false) @@ -117,13 +121,14 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } + // MARK: - of VisibleMessage context("of VisibleMessage") { beforeEach { interaction = Interaction( @@ -162,6 +167,7 @@ class MessageSendJobSpec: QuickSpec { } } + // MARK: -- fails when there is no job id it("fails when there is no job id") { job = Job( variant: .messageSend, @@ -186,13 +192,14 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } + // MARK: -- fails when there is no interaction id it("fails when there is no interaction id") { job = Job( variant: .messageSend, @@ -216,13 +223,14 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } + // MARK: -- fails when there is no interaction for the provided interaction id it("fails when there is no interaction for the provided interaction id") { job = Job( variant: .messageSend, @@ -248,29 +256,32 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(StorageError.objectNotFound)) expect(permanentFailure).to(beTrue()) } + + // MARK: -- with an attachment context("with an attachment") { beforeEach { - interactionAttachment1 = InteractionAttachment( + interactionAttachment = InteractionAttachment( albumIndex: 0, interactionId: interaction.id!, - attachmentId: attachment1.id + attachmentId: attachment.id ) mockStorage.write { db in - try attachment1.insert(db) - try interactionAttachment1.insert(db) + try attachment.insert(db) + try interactionAttachment.insert(db) } } + // MARK: ---- it fails when trying to send with an attachment which previously failed to download it("it fails when trying to send with an attachment which previously failed to download") { mockStorage.write { db in - try attachment1.with(state: .failedDownload).save(db) + try attachment.with(state: .failedDownload).save(db) } var error: Error? = nil @@ -285,54 +296,27 @@ class MessageSendJobSpec: QuickSpec { permanentFailure = runPermanentFailure }, deferred: { _, _ in }, - dependencies: dependencies - ) - - expect(error).to(matchError(AttachmentError.notUploaded)) - expect(permanentFailure).to(beTrue()) - } - - it("it fails when trying to send with an attachment that has an invalid downloadUrl") { - mockStorage.write { db in - try attachment1 - .with( - state: .uploaded, - downloadUrl: nil - ) - .save(db) - } - - var error: Error? = nil - var permanentFailure: Bool = false - - MessageSendJob.run( - job, - queue: .main, - success: { _, _, _ in }, - failure: { _, runError, runPermanentFailure, _ in - error = runError - permanentFailure = runPermanentFailure - }, - deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(error).to(matchError(AttachmentError.notUploaded)) expect(permanentFailure).to(beTrue()) } + // MARK: ---- with a pending upload context("with a pending upload") { beforeEach { mockStorage.write { db in - try attachment1.with(state: .uploading).save(db) + try attachment.with(state: .uploading).save(db) } } + // MARK: ------ it defers when trying to send with an attachment which is still pending upload it("it defers when trying to send with an attachment which is still pending upload") { var didDefer: Bool = false mockStorage.write { db in - try attachment1.with(state: .uploading).save(db) + try attachment.with(state: .uploading).save(db) } MessageSendJob.run( @@ -341,12 +325,38 @@ class MessageSendJobSpec: QuickSpec { success: { _, _, _ in }, failure: { _, _, _, _ in }, deferred: { _, _ in didDefer = true }, - dependencies: dependencies + using: dependencies ) expect(didDefer).to(beTrue()) } + // MARK: ------ it defers when trying to send with an uploaded attachment that has an invalid downloadUrl + it("it defers when trying to send with an uploaded attachment that has an invalid downloadUrl") { + var didDefer: Bool = false + + mockStorage.write { db in + try attachment + .with( + state: .uploaded, + downloadUrl: nil + ) + .save(db) + } + + MessageSendJob.run( + job, + queue: .main, + success: { _, _, _ in }, + failure: { _, _, _, _ in }, + deferred: { _, _ in didDefer = true }, + using: dependencies + ) + + expect(didDefer).to(beTrue()) + } + + // MARK: ------ inserts an attachment upload job before the message send job it("inserts an attachment upload job before the message send job") { mockJobRunner .when { @@ -356,17 +366,7 @@ class MessageSendJobSpec: QuickSpec { variant: .attachmentUpload ) } - .thenReturn([ - 2: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: 100, - detailsData: try! JSONEncoder().encode(AttachmentUploadJob.Details( - messageSendJobId: 1, - attachmentId: "200" - )) - ) - ]) + .thenReturn([:]) MessageSendJob.run( job, @@ -374,7 +374,7 @@ class MessageSendJobSpec: QuickSpec { success: { _, _, _ in }, failure: { _, _, _, _ in }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(mockJobRunner) @@ -392,12 +392,12 @@ class MessageSendJobSpec: QuickSpec { attachmentId: "200" ) ), - before: job, - dependencies: dependencies + before: job ) }) } + // MARK: ------ creates a dependency between the new job and the existing one it("creates a dependency between the new job and the existing one") { MessageSendJob.run( job, @@ -405,7 +405,7 @@ class MessageSendJobSpec: QuickSpec { success: { _, _, _ in }, failure: { _, _, _, _ in }, deferred: { _, _ in }, - dependencies: dependencies + using: dependencies ) expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) diff --git a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift index 345b3044d..1d8508abe 100644 --- a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift @@ -44,11 +44,13 @@ class BatchRequestInfoSpec: QuickSpec { ) ] ) - let requestData: Data = try! JSONEncoder().encode(request) - let requestString: String? = String(data: requestData, encoding: .utf8) - expect(requestString) - .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"b64\":\"testBody\"}]")) + let requestData: Data? = try? JSONEncoder().encode(request) + let requestJson: [[String: Any]]? = requestData + .map { try? JSONSerialization.jsonObject(with: $0) as? [[String: Any]] } + expect(requestJson?.first?["path"] as? String).to(equal("/batch")) + expect(requestJson?.first?["method"] as? String).to(equal("GET")) + expect(requestJson?.first?["b64"] as? String).to(equal("testBody")) } it("successfully encodes a byte body") { @@ -70,11 +72,13 @@ class BatchRequestInfoSpec: QuickSpec { ) ] ) - let requestData: Data = try! JSONEncoder().encode(request) - let requestString: String? = String(data: requestData, encoding: .utf8) - expect(requestString) - .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"bytes\":[1,2,3]}]")) + let requestData: Data? = try? JSONEncoder().encode(request) + let requestJson: [[String: Any]]? = requestData + .map { try? JSONSerialization.jsonObject(with: $0) as? [[String: Any]] } + expect(requestJson?.first?["path"] as? String).to(equal("/batch")) + expect(requestJson?.first?["method"] as? String).to(equal("GET")) + expect(requestJson?.first?["bytes"] as? [Int]).to(equal([1, 2, 3])) } it("successfully encodes a JSON body") { @@ -96,11 +100,13 @@ class BatchRequestInfoSpec: QuickSpec { ) ] ) - let requestData: Data = try! JSONEncoder().encode(request) - let requestString: String? = String(data: requestData, encoding: .utf8) - expect(requestString) - .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"json\":{\"stringValue\":\"testValue\"}}]")) + let requestData: Data? = try? JSONEncoder().encode(request) + let requestJson: [[String: Any]]? = requestData + .map { try? JSONSerialization.jsonObject(with: $0) as? [[String: Any]] } + expect(requestJson?.first?["path"] as? String).to(equal("/batch")) + expect(requestJson?.first?["method"] as? String).to(equal("GET")) + expect(requestJson?.first?["json"] as? [String: String]).to(equal(["stringValue": "testValue"])) } it("strips authentication headers") { diff --git a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift index 176201e26..040785fe8 100644 --- a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift @@ -16,9 +16,8 @@ class SOGSMessageSpec: QuickSpec { var messageJson: String! var messageData: Data! var decoder: JSONDecoder! - var mockSign: MockSign! - var mockEd25519: MockEd25519! - var dependencies: SMKDependencies! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! beforeEach { messageJson = """ @@ -35,18 +34,16 @@ class SOGSMessageSpec: QuickSpec { } """ messageData = messageJson.data(using: .utf8)! - mockSign = MockSign() - mockEd25519 = MockEd25519() - dependencies = SMKDependencies( - sign: mockSign, - ed25519: mockEd25519 + mockCrypto = MockCrypto() + dependencies = Dependencies( + crypto: mockCrypto ) decoder = JSONDecoder() decoder.userInfo = [ Dependencies.userInfoKey: dependencies as Any ] } afterEach { - mockSign = nil + mockCrypto = nil } context("when decoding") { @@ -204,8 +201,10 @@ class SOGSMessageSpec: QuickSpec { } it("succeeds if it succeeds verification") { - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { + $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) + } .thenReturn(true) expect { @@ -215,25 +214,31 @@ class SOGSMessageSpec: QuickSpec { } it("provides the correct values as parameters") { - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { + $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) + } .thenReturn(true) _ = try? decoder.decode(OpenGroupAPI.Message.self, from: messageData) - expect(mockSign) + expect(mockCrypto) .to(call(matchingParameters: true) { $0.verify( - message: Data(base64Encoded: "VGVzdERhdGE=")!.bytes, - publicKey: Data(hex: TestConstants.publicKey).bytes, - signature: Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!.bytes + .signature( + message: Data(base64Encoded: "VGVzdERhdGE=")!.bytes, + publicKey: Data(hex: TestConstants.publicKey).bytes, + signature: Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!.bytes + ) ) }) } it("throws if it fails verification") { - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { + $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) + } .thenReturn(false) expect { @@ -245,7 +250,9 @@ class SOGSMessageSpec: QuickSpec { context("that is unblinded") { it("succeeds if it succeeds verification") { - mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(true) + mockCrypto + .when { $0.verify(.signatureEd25519(any(), publicKey: any(), data: any())) } + .thenReturn(true) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) @@ -254,22 +261,28 @@ class SOGSMessageSpec: QuickSpec { } it("provides the correct values as parameters") { - mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(true) + mockCrypto + .when { $0.verify(.signatureEd25519(any(), publicKey: any(), data: any())) } + .thenReturn(true) _ = try? decoder.decode(OpenGroupAPI.Message.self, from: messageData) - expect(mockEd25519) + expect(mockCrypto) .to(call(matchingParameters: true) { - try $0.verifySignature( - Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!, - publicKey: Data(hex: TestConstants.publicKey), - data: Data(base64Encoded: "VGVzdERhdGE=")! + $0.verify( + .signatureEd25519( + Data(base64Encoded: "VGVzdFNpZ25hdHVyZQ==")!, + publicKey: Data(hex: TestConstants.publicKey), + data: Data(base64Encoded: "VGVzdERhdGE=")! + ) ) }) } it("throws if it fails verification") { - mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(false) + mockCrypto + .when { $0.verify(.signatureEd25519(any(), publicKey: any(), data: any())) } + .thenReturn(false) expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index d87bfdca3..8f815c2eb 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -17,49 +17,31 @@ class OpenGroupAPISpec: QuickSpec { override func spec() { var mockStorage: Storage! - var mockSodium: MockSodium! - var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! - var mockSign: MockSign! - var mockGenericHash: MockGenericHash! - var mockEd25519: MockEd25519! - var mockNonce16Generator: MockNonce16Generator! - var mockNonce24Generator: MockNonce24Generator! - var dependencies: SMKDependencies! + var mockNetwork: MockNetwork! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! var disposables: [AnyCancellable] = [] - var response: (ResponseInfoType, Codable)? = nil - var pollResponse: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)? var error: Error? describe("an OpenGroupAPI") { // MARK: - Configuration beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, SNMessagingKit.self ] ) - mockSodium = MockSodium() - mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() - mockSign = MockSign() - mockGenericHash = MockGenericHash() - mockNonce16Generator = MockNonce16Generator() - mockNonce24Generator = MockNonce24Generator() - mockEd25519 = MockEd25519() - dependencies = SMKDependencies( - onionApi: TestOnionRequestAPI.self, + mockNetwork = MockNetwork() + mockCrypto = MockCrypto() + dependencies = Dependencies( storage: mockStorage, - sodium: mockSodium, - genericHash: mockGenericHash, - sign: mockSign, - aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, - ed25519: mockEd25519, - nonceGenerator16: mockNonce16Generator, - nonceGenerator24: mockNonce24Generator, - date: Date(timeIntervalSince1970: 1234567890) + network: mockNetwork, + crypto: mockCrypto, + dateNow: Date(timeIntervalSince1970: 1234567890) ) mockStorage.write { db in @@ -85,33 +67,42 @@ class OpenGroupAPISpec: QuickSpec { try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) } - mockGenericHash.when { $0.hash(message: anyArray(), outputLength: any()) }.thenReturn([]) - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + mockCrypto + .when { try $0.perform(.hash(message: anyArray(), outputLength: any())) } + .thenReturn([]) + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate(.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), using: dependencies)) + } .thenReturn( KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) ) - mockSodium + mockCrypto .when { - $0.sogsSignature( - message: anyArray(), - secretKey: anyArray(), - blindedSecretKey: anyArray(), - blindedPublicKey: anyArray() + try $0.perform( + .sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) ) } .thenReturn("TestSogsSignature".bytes) - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn("TestSignature".bytes) - mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn("TestStandardSignature".bytes) - - mockNonce16Generator - .when { $0.nonce() } + mockCrypto + .when { try $0.perform(.signature(message: anyArray(), secretKey: anyArray())) } + .thenReturn("TestSignature".bytes) + mockCrypto + .when { try $0.perform(.signEd25519(data: anyArray(), keyPair: any())) } + .thenReturn("TestStandardSignature".bytes) + mockCrypto + .when { try $0.perform(.generateNonce16()) } .thenReturn(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes) - mockNonce24Generator - .when { $0.nonce() } + mockCrypto + .when { try $0.perform(.generateNonce24()) } .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) } @@ -119,733 +110,307 @@ class OpenGroupAPISpec: QuickSpec { disposables.forEach { $0.cancel() } mockStorage = nil - mockSodium = nil - mockAeadXChaCha20Poly1305Ietf = nil - mockSign = nil - mockGenericHash = nil - mockEd25519 = nil + mockNetwork = nil + mockCrypto = nil dependencies = nil disposables = [] - response = nil - pollResponse = nil error = nil } - // MARK: - Batching & Polling - - context("when polling") { - context("and given a correct response") { - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.Message](), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - - dependencies = dependencies.with(onionApi: TestApi.self) + // MARK: - when preparing a poll request + context("when preparing a poll request") { + // MARK: -- generates the correct request + it("generates the correct request") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) } - it("generates the correct request") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(pollResponse?.data.count).to(equal(3)) - expect(pollResponse?.data.keys).to(contain(.capabilities)) - expect(pollResponse?.data.keys).to(contain(.roomPollInfo("testRoom", 0))) - expect(pollResponse?.data.keys).to(contain(.roomMessagesRecent("testRoom"))) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (pollResponse?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testserver/batch")) - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/batch")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + expect(preparedRequest?.batchEndpoints.count).to(equal(3)) + expect(preparedRequest?.batchEndpoints[test: 0]).to(equal(.capabilities)) + expect(preparedRequest?.batchEndpoints[test: 1]).to(equal(.roomPollInfo("testRoom", 0))) + expect(preparedRequest?.batchEndpoints[test: 2]).to(equal(.roomMessagesRecent("testRoom"))) + } + + // MARK: -- retrieves recent messages if there was no last message + it("retrieves recent messages if there was no last message") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) } - it("retrieves recent messages if there was no last message") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.roomMessagesRecent("testRoom"))) + expect(preparedRequest?.batchEndpoints[test: 2]).to(equal(.roomMessagesRecent("testRoom"))) + } + + // MARK: -- retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago + it("retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 121)) } - it("retrieves recent messages if there was a last message and it has not performed the initial poll and the last message was too long ago") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: (OpenGroupAPI.Poller.maxInactivityPeriod + 1), - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.roomMessagesRecent("testRoom"))) + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: (OpenGroupAPI.Poller.maxInactivityPeriod + 1), + using: dependencies + ) } - it("retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) + expect(preparedRequest?.batchEndpoints[test: 2]).to(equal(.roomMessagesRecent("testRoom"))) + } + + // MARK: -- retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago + it("retrieves recent messages if there was a last message and it has performed an initial poll but it was not too long ago") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 122)) } - it("retrieves recent messages if there was a last message and there has already been a poll this session") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) - } + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies + ) + } + + expect(preparedRequest?.batchEndpoints[test: 2]) + .to(equal(.roomMessagesSince("testRoom", seqNo: 122))) + } + + // MARK: -- retrieves recent messages if there was a last message and there has already been a poll this session + it("retrieves recent messages if there was a last message and there has already been a poll this session") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) + } - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies + ) + } + + expect(preparedRequest?.batchEndpoints[test: 2]) + .to(equal(.roomMessagesSince("testRoom", seqNo: 123))) + } + + // MARK: -- when unblinded + context("when unblinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + } + + // MARK: ---- does not call the inbox and outbox endpoints + it("does not call the inbox and outbox endpoints") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) - } - - context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - - it("does not call the inbox and outbox endpoints") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(pollResponse?.data.keys).toNot(contain(.inbox)) - expect(pollResponse?.data.keys).toNot(contain(.outbox)) - } - } - - context("when blinded") { - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.Message](), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.DirectMessage](), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: [OpenGroupAPI.DirectMessage](), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - - it("includes the inbox and outbox endpoints") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(pollResponse?.data.keys).to(contain(.inbox)) - expect(pollResponse?.data.keys).to(contain(.outbox)) } - it("retrieves recent inbox messages if there was no last message") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.inbox)) - } - - it("retrieves inbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.inboxSince(id: 124))) - } - - it("retrieves recent outbox messages if there was no last message") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.outbox)) - } - - it("retrieves outbox messages since the last message if there was one") { - mockStorage.write { db in - try OpenGroup - .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: 125)) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: true, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.data.keys).to(contain(.outboxSince(id: 125))) - } + expect(preparedRequest?.batchEndpoints).toNot(contain(.inbox)) + expect(preparedRequest?.batchEndpoints).toNot(contain(.outbox)) } } - context("and given an invalid response") { - it("succeeds but flags the bodies it failed to parse when an unexpected response is returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } + // MARK: -- when blinded + context("when blinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(pollResponse) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) + } + + // MARK: ---- includes the inbox and outbox endpoints + it("includes the inbox and outbox endpoints") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: false, + timeSinceLastPoll: 0, + using: dependencies ) - expect(error?.localizedDescription).to(beNil()) + } - let capabilitiesResponse: HTTP.BatchSubResponse? = (pollResponse?.data[.capabilities] as? HTTP.BatchSubResponse) - let pollInfoResponse: HTTP.BatchSubResponse? = (pollResponse?.data[.roomPollInfo("testRoom", 0)] as? HTTP.BatchSubResponse) - let messagesResponse: HTTP.BatchSubResponse<[Failable]>? = (pollResponse?.data[.roomMessagesRecent("testRoom")] as? HTTP.BatchSubResponse<[Failable]>) - expect(capabilitiesResponse?.failedToParseBody).to(beFalse()) - expect(pollInfoResponse?.failedToParseBody).to(beTrue()) - expect(messagesResponse?.failedToParseBody).to(beTrue()) + expect(preparedRequest?.batchEndpoints).to(contain(.inbox)) + expect(preparedRequest?.batchEndpoints).to(contain(.outbox)) } - it("errors when no data is returned") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) + // MARK: ---- retrieves recent inbox messages if there was no last message + it("retrieves recent inbox messages if there was no last message") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies ) + } - expect(pollResponse).to(beNil()) + expect(preparedRequest?.batchEndpoints).to(contain(.inbox)) } - it("errors when invalid data is returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } + // MARK: ---- retrieves inbox messages since the last message if there was one + it("retrieves inbox messages since the last message if there was one") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) } - dependencies = dependencies.with(onionApi: TestApi.self) - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies ) + } - expect(pollResponse).to(beNil()) + expect(preparedRequest?.batchEndpoints).to(contain(.inboxSince(id: 124))) } - it("errors when an empty array is returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return "[]".data(using: .utf8) } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) + // MARK: ---- retrieves recent outbox messages if there was no last message + it("retrieves recent outbox messages if there was no last message") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies ) + } - expect(pollResponse).to(beNil()) + expect(preparedRequest?.batchEndpoints).to(contain(.outbox)) } - it("errors when an empty object is returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return "{}".data(using: .utf8) } + // MARK: ---- retrieves outbox messages since the last message if there was one + it("retrieves outbox messages since the last message if there was one") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: 125)) } - dependencies = dependencies.with(onionApi: TestApi.self) - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPoll( + db, + server: "testserver", + hasPerformedInitialPoll: true, + timeSinceLastPoll: 0, + using: dependencies ) - - expect(pollResponse).to(beNil()) - } - - it("errors when a different number of responses are returned") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: try! JSONDecoder().decode( - OpenGroupAPI.RoomPollInfo.self, - from: """ - { - \"token\":\"test\", - \"active_users\":1, - \"read\":true, - \"write\":true, - \"upload\":true - } - """.data(using: .utf8)! - ), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } } - dependencies = dependencies.with(onionApi: TestApi.self) - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedPoll( - db, - server: "testserver", - hasPerformedInitialPoll: false, - timeSinceLastPoll: 0, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in pollResponse = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(pollResponse).to(beNil()) + expect(preparedRequest?.batchEndpoints).to(contain(.outboxSince(id: 125))) } } } - // MARK: - Capabilities - - context("when doing a capabilities request") { + // MARK: - when preparing a capabilities request + context("when preparing a capabilities request") { + // MARK: -- generates the request correctly it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - static let data: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - - override class var mockResponse: Data? { try! JSONEncoder().encode(data) } + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedCapabilities( + db, + server: "testserver", + using: dependencies + ) } - dependencies = dependencies.with(onionApi: TestApi.self) - var response: (info: ResponseInfoType, data: OpenGroupAPI.Capabilities)? + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/capabilities")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + } + } + + // MARK: - when preparing a rooms request + context("when preparing a rooms request") { + + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/rooms")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + } + } + + // MARK: - when preparing a capabilitiesAndRoom request + context("when preparing a capabilitiesAndRoom request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.batchEndpoints.count).to(equal(2)) + expect(preparedRequest?.batchEndpoints[test: 0]).to(equal(.capabilities)) + expect(preparedRequest?.batchEndpoints[test: 1]).to(equal(.room("testRoom"))) + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/sequence")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- processes a valid response correctly + it("processes a valid response correctly") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndRoomResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage .readPublisher { db in - try OpenGroupAPI.preparedCapabilities( + try OpenGroupAPI.preparedCapabilitiesAndRoom( db, - server: "testserver", + for: "testRoom", + on: "testserver", using: dependencies ) } @@ -854,62 +419,1419 @@ class OpenGroupAPISpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) + expect(response).toNot(beNil()) + expect(error).to(beNil()) + } + + // MARK: -- and given an invalid response + + context("and given an invalid response") { + // MARK: ---- errors when not given a room response + it("errors when not given a room response") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndBanResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(response).to(beNil()) + } - // Validate the response data - expect(response?.data).to(equal(TestApi.data)) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.urlString).to(equal("testserver/capabilities")) + // MARK: ---- errors when not given a capabilities response + it("errors when not given a capabilities response") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockBanAndRoomResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(response).to(beNil()) + } } } - // MARK: - Rooms - - context("when doing a rooms request") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - static let data: [OpenGroupAPI.Room] = [ - OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - ] - - override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } + // MARK: - when preparing a capabilitiesAndRooms request + context("when preparing a capabilitiesAndRooms request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: "testserver", + using: dependencies + ) } - dependencies = dependencies.with(onionApi: TestApi.self) + expect(preparedRequest?.batchEndpoints.count).to(equal(2)) + expect(preparedRequest?.batchEndpoints[test: 0]).to(equal(.capabilities)) + expect(preparedRequest?.batchEndpoints[test: 1]).to(equal(.rooms)) + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/sequence")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- processes a valid response correctly + it("processes a valid response correctly") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndRoomsResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(response).toNot(beNil()) + expect(error).to(beNil()) + } + + // MARK: -- and given an invalid response + + context("and given an invalid response") { + // MARK: ---- errors when not given a room response + it("errors when not given a room response") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndBanResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(response).to(beNil()) + } + + // MARK: ---- errors when not given a capabilities response + it("errors when not given a capabilities response") { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockBanAndRoomsResponse) + + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? + + mockStorage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: "testserver", + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) + + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(response).to(beNil()) + } + } + } + + // MARK: - when preparing a send message request + context("when preparing a send message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testServer/room/testRoom/message")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- when unblinded + context("when unblinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + } + + // MARK: ---- signs the message correctly + it("signs the message correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.SendMessageRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.SendMessageRequest.self, using: dependencies) + expect(requestBody?.data).to(equal("test".data(using: .utf8))) + expect(requestBody?.signature).to(equal("TestStandardSignature".data(using: .utf8))) + } + + // MARK: ---- fails to sign if there is no open group + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if there is no user key pair + it("fails to sign if there is no user key pair") { + mockStorage.write { db in + _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if no signature is generated + it("fails to sign if no signature is generated") { + mockCrypto.reset() // The 'keyPair' value doesn't equate so have to explicitly reset + mockCrypto + .when { try $0.perform(.signEd25519(data: anyArray(), keyPair: any())) } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + + // MARK: -- when blinded + context("when blinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } + } + + // MARK: ---- signs the message correctly + it("signs the message correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.SendMessageRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.SendMessageRequest.self, using: dependencies) + expect(requestBody?.data).to(equal("test".data(using: .utf8))) + expect(requestBody?.signature).to(equal("TestSogsSignature".data(using: .utf8))) + } + + // MARK: ---- fails to sign if there is no open group + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testServer", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if there is no ed key pair key + it("fails to sign if there is no ed key pair key") { + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if no signature is generated + it("fails to sign if no signature is generated") { + mockCrypto + .when { + try $0.perform( + .sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + ) + } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedSend( + db, + plaintext: "test".data(using: .utf8)!, + to: "testRoom", + on: "testserver", + whisperTo: nil, + whisperMods: false, + fileIds: nil, + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + } + + // MARK: - when preparing an individual message request + context("when preparing an individual message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessage( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/message/123")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + } + } + + // MARK: - when preparing an update message request + context("when preparing an update message request") { + beforeEach { + mockStorage.write { db in + _ = try Identity + .filter(id: .ed25519PublicKey) + .updateAll(db, Identity.Columns.data.set(to: Data())) + _ = try Identity + .filter(id: .ed25519SecretKey) + .updateAll(db, Identity.Columns.data.set(to: Data())) + } + } + + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/message/123")) + expect(preparedRequest?.request.httpMethod).to(equal("PUT")) + } + + // MARK: -- when unblinded + context("when unblinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + } + + // MARK: ---- signs the message correctly + it("signs the message correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UpdateMessageRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UpdateMessageRequest.self, using: dependencies) + expect(requestBody?.data).to(equal("test".data(using: .utf8))) + expect(requestBody?.signature).to(equal("TestStandardSignature".data(using: .utf8))) + } + + // MARK: ---- fails to sign if there is no open group + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if there is no user key pair + it("fails to sign if there is no user key pair") { + mockStorage.write { db in + _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if no signature is generated + it("fails to sign if no signature is generated") { + mockCrypto.reset() // The 'keyPair' value doesn't equate so have to explicitly reset + mockCrypto + .when { try $0.perform(.signEd25519(data: anyArray(), keyPair: any())) } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testServer", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + + // MARK: -- when blinded + context("when blinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } + } + + // MARK: ---- signs the message correctly + it("signs the message correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UpdateMessageRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UpdateMessageRequest.self, using: dependencies) + expect(requestBody?.data).to(equal("test".data(using: .utf8))) + expect(requestBody?.signature).to(equal("TestSogsSignature".data(using: .utf8))) + } + + // MARK: ---- fails to sign if there is no open group + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if there is no ed key pair key + it("fails to sign if there is no ed key pair key") { + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails to sign if no signature is generated + it("fails to sign if no signature is generated") { + mockCrypto + .when { + try $0.perform( + .sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + ) + } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedMessageUpdate( + db, + id: 123, + plaintext: "test".data(using: .utf8)!, + fileIds: nil, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + } + + // MARK: - when preparing a delete message request + context("when preparing a delete message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessageDelete( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/message/123")) + expect(preparedRequest?.request.httpMethod).to(equal("DELETE")) + } + } + + // MARK: - when preparing a delete all messages request + context("when preparing a delete all messages request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedMessagesDeleteAll( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/all/testUserId")) + expect(preparedRequest?.request.httpMethod).to(equal("DELETE")) + } + } + + // MARK: - when preparing a pin message request + context("when preparing a pin message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedPinMessage( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/pin/123")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + } + + // MARK: - when preparing an unpin message request + context("when preparing an unpin message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUnpinMessage( + db, + id: 123, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/unpin/123")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + } + + // MARK: - when preparing an unpin all request + context("when preparing an unpin all request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUnpinAll( + db, + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/unpin/all")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + } + + // MARK: - when preparing an upload file request + context("when preparing an upload file request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUploadFile( + db, + bytes: [], + to: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/file")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- doesn't add a fileName to the content-disposition header when not provided + it("doesn't add a fileName to the content-disposition header when not provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUploadFile( + db, + bytes: [], + to: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.contentDisposition]) + .toNot(contain("filename")) + } + + // MARK: -- adds the fileName to the content-disposition header when provided + it("adds the fileName to the content-disposition header when provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUploadFile( + db, + bytes: [], + fileName: "TestFileName", + to: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.contentDisposition]) + .to(contain("TestFileName")) + } + } + + // MARK: - when preparing a download file request + context("when preparing a download file request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedDownloadFile( + db, + fileId: "1", + from: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/room/testRoom/file/1")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + } + } + + // MARK: - when preparing a send direct message request + context("when preparing a send direct message request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedSend( + db, + ciphertext: "test".data(using: .utf8)!, + toInboxFor: "testUserId", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/inbox/testUserId")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + } + + // MARK: - when preparing a ban user request + context("when preparing a ban user request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserBan( + db, + sessionId: "testUserId", + for: nil, + from: nil, + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/user/testUserId/ban")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- does a global ban if no room tokens are provided + it("does a global ban if no room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserBan( + db, + sessionId: "testUserId", + for: nil, + from: nil, + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserBanRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserBanRequest.self, using: dependencies) + expect(requestBody?.global).to(beTrue()) + expect(requestBody?.rooms).to(beNil()) + } + + // MARK: -- does room specific bans if room tokens are provided + it("does room specific bans if room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserBan( + db, + sessionId: "testUserId", + for: nil, + from: ["testRoom"], + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserBanRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserBanRequest.self, using: dependencies) + expect(requestBody?.global).to(beNil()) + expect(requestBody?.rooms).to(equal(["testRoom"])) + } + } + + // MARK: - when preparing an unban user request + context("when preparing an unban user request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserUnban( + db, + sessionId: "testUserId", + from: nil, + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/user/testUserId/unban")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- does a global unban if no room tokens are provided + it("does a global unban if no room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserUnban( + db, + sessionId: "testUserId", + from: nil, + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserUnbanRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserUnbanRequest.self, using: dependencies) + expect(requestBody?.global).to(beTrue()) + expect(requestBody?.rooms).to(beNil()) + } + + // MARK: -- does room specific unbans if room tokens are provided + it("does room specific unbans if room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserUnban( + db, + sessionId: "testUserId", + from: ["testRoom"], + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserUnbanRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserUnbanRequest.self, using: dependencies) + expect(requestBody?.global).to(beNil()) + expect(requestBody?.rooms).to(equal(["testRoom"])) + } + } + + // MARK: - when preparing a user permissions request + context("when preparing a user permissions request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserModeratorUpdate( + db, + sessionId: "testUserId", + moderator: true, + admin: nil, + visible: true, + for: nil, + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/user/testUserId/moderator")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + } + + // MARK: -- does a global update if no room tokens are provided + it("does a global update if no room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserModeratorUpdate( + db, + sessionId: "testUserId", + moderator: true, + admin: nil, + visible: true, + for: nil, + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserModeratorRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserModeratorRequest.self, using: dependencies) + expect(requestBody?.global).to(beTrue()) + expect(requestBody?.rooms).to(beNil()) + } + + // MARK: -- does room specific updates if room tokens are provided + it("does room specific updates if room tokens are provided") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserModeratorUpdate( + db, + sessionId: "testUserId", + moderator: true, + admin: nil, + visible: true, + for: ["testRoom"], + on: "testserver", + using: dependencies + ) + } + + let requestBody: OpenGroupAPI.UserModeratorRequest? = try? preparedRequest?.request.httpBody? + .decoded(as: OpenGroupAPI.UserModeratorRequest.self, using: dependencies) + expect(requestBody?.global).to(beNil()) + expect(requestBody?.rooms).to(equal(["testRoom"])) + } + + // MARK: -- fails if neither moderator or admin are set + it("fails if neither moderator or admin are set") { + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedUserModeratorUpdate( + db, + sessionId: "testUserId", + moderator: nil, + admin: nil, + visible: true, + for: nil, + on: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(HTTPError.generic)) + expect(preparedRequest).to(beNil()) + } + } + + // MARK: - when preparing a ban and delete all request + context("when preparing a ban and delete all request") { + // MARK: -- generates the request correctly + it("generates the request correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in + try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/sequence")) + expect(preparedRequest?.request.httpMethod).to(equal("POST")) + expect(preparedRequest?.batchEndpoints.count).to(equal(2)) + expect(preparedRequest?.batchEndpoints[test: 0]).to(equal(.userBan("testUserId"))) + expect(preparedRequest?.batchEndpoints[test: 1]) + .to(equal(.roomDeleteMessages("testRoom", sessionId: "testUserId"))) + } + +// // MARK: -- bans the user from the specified room rather than globally +// it("bans the user from the specified room rather than globally") { +// let preparedRequest: OpenGroupAPI.PreparedSendData? = mockStorage.read { db in +// try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( +// db, +// sessionId: "testUserId", +// in: "testRoom", +// on: "testserver", +// using: dependencies +// ) +// } +// +// let requestBody: OpenGroupAPI.UserBanRequest? = preparedRequest?.batchRequestBodies[test: 0]? +// .decoded(as: OpenGroupAPI.UserBanRequest.self) +// expect(requestBody?.global).to(beNil()) +// expect(requestBody?.rooms).to(equal(["testRoom"])) +// } + } + + // MARK: - when signing + context("when signing") { + // MARK: -- fails when there is no serverPublicKey + it("fails when there is no serverPublicKey") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.noPublicKey)) + expect(preparedRequest).to(beNil()) + } + + // MARK: -- fails when there is no userEdKeyPair + it("fails when there is no userEdKeyPair") { + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: -- fails when the serverPublicKey is not a hex string + it("fails when the serverPublicKey is not a hex string") { + mockStorage.write { db in + _ = try OpenGroup.updateAll(db, OpenGroup.Columns.publicKey.set(to: "TestString!!!")) + } + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: -- when unblinded + context("when unblinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + } + + // MARK: ---- signs correctly + it("signs correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/rooms")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + expect(preparedRequest?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(preparedRequest?.request.allHTTPHeaderFields).to(haveCount(4)) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsPubKey]) + .to(equal("00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsTimestamp]) + .to(equal("1234567890")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsNonce]) + .to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsSignature]) + .to(equal("TestSignature".bytes.toBase64())) + } + + // MARK: ---- fails when the signature is not generated + it("fails when the signature is not generated") { + mockCrypto + .when { try $0.perform(.signature(message: anyArray(), secretKey: anyArray())) } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + + // MARK: -- when blinded + context("when blinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) + } + } + + // MARK: ---- signs correctly + it("signs correctly") { + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + + expect(preparedRequest?.request.url?.absoluteString).to(equal("testserver/rooms")) + expect(preparedRequest?.request.httpMethod).to(equal("GET")) + expect(preparedRequest?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(preparedRequest?.request.allHTTPHeaderFields).to(haveCount(4)) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsPubKey]) + .to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsTimestamp]) + .to(equal("1234567890")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsNonce]) + .to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(preparedRequest?.request.allHTTPHeaderFields?[HTTPHeader.sogsSignature]) + .to(equal("TestSogsSignature".bytes.toBase64())) + } + + // MARK: ---- fails when the blindedKeyPair is not generated + it("fails when the blindedKeyPair is not generated") { + mockCrypto + .when { [dependencies = dependencies!] in + $0.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) + ) + } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + + // MARK: ---- fails when the sogsSignature is not generated + it("fails when the sogsSignature is not generated") { + mockCrypto + .when { [dependencies = dependencies!] in + $0.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) + ) + } + .thenReturn(nil) + + var preparationError: Error? + let preparedRequest: OpenGroupAPI.PreparedSendData<[OpenGroupAPI.Room]>? = mockStorage.read { db in + do { + return try OpenGroupAPI.preparedRooms( + db, + server: "testserver", + using: dependencies + ) + } + catch { + preparationError = error + throw error + } + } + + expect(preparationError).to(matchError(OpenGroupAPIError.signingFailed)) + expect(preparedRequest).to(beNil()) + } + } + } + + // MARK: -- when sending + context("when sending") { + beforeEach { + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.response(type: [OpenGroupAPI.Room].self)) + } + + // MARK: -- triggers sending correctly + it("triggers sending correctly") { var response: (info: ResponseInfoType, data: [OpenGroupAPI.Room])? mockStorage @@ -924,2280 +1846,67 @@ class OpenGroupAPISpec: QuickSpec { .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data).to(equal(TestApi.data)) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.urlString).to(equal("testserver/rooms")) - } - } - - // MARK: - CapabilitiesAndRoom - - context("when doing a capabilitiesAndRoom request") { - context("and given a correct response") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data.capabilities.data).to(equal(TestApi.capabilitiesData)) - expect(response?.data.room.data).to(equal(TestApi.roomData)) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/sequence")) - } + + expect(response).toNot(beNil()) + expect(error).to(beNil()) } - context("and given an invalid response") { - it("errors when only a capabilities response is returned") { - class TestApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } + // MARK: -- fails when not given prepared data + it("fails when not given prepared data") { + var response: (info: ResponseInfoType, data: [OpenGroupAPI.Room])? - it("errors when only a room response is returned") { - class TestApi: TestOnionRequestAPI { - static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("errors when an extra response is returned") { - class TestApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedCapabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - } - - // MARK: - Messages - - context("when sending messages") { - var messageData: OpenGroupAPI.Message! - - beforeEach { - class TestApi: TestOnionRequestAPI { - static let data: OpenGroupAPI.Message = OpenGroupAPI.Message( - id: 126, - sender: "testSender", - posted: 321, - edited: nil, - deleted: nil, - seqNo: 10, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: nil, - base64EncodedSignature: nil, - reactions: nil - ) - - override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } - } - messageData = TestApi.data - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - messageData = nil - } - - it("correctly sends the message") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + OpenGroupAPI.send(data: nil, using: dependencies) .handleEvents(receiveOutput: { result in response = result }) .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data).to(equal(messageData)) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testServer/room/testRoom/message")) - } - - context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - - it("signs the message correctly") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request body - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.SendMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.SendMessageRequest.self, from: requestData!.body!) - - expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestStandardSignature".data(using: .utf8))) - } - - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if there is no user key pair") { - mockStorage.write { db in - _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) - } - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if no signature is generated") { - mockEd25519.reset() // The 'keyPair' value doesn't equate so have to explicitly reset - mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn(nil) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - - context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - - it("signs the message correctly") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request body - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.SendMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.SendMessageRequest.self, from: requestData!.body!) - - expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestSogsSignature".data(using: .utf8))) - } - - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testServer", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if there is no ed key pair key") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if no signature is generated") { - mockSodium - .when { - $0.sogsSignature( - message: anyArray(), - secretKey: anyArray(), - blindedSecretKey: anyArray(), - blindedPublicKey: anyArray() - ) - } - .thenReturn(nil) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - plaintext: "test".data(using: .utf8)!, - to: "testRoom", - on: "testserver", - whisperTo: nil, - whisperMods: false, - fileIds: nil, - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - } - - context("when getting an individual message") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - static let data: OpenGroupAPI.Message = OpenGroupAPI.Message( - id: 126, - sender: "testSender", - posted: 321, - edited: nil, - deleted: nil, - seqNo: 10, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: nil, - base64EncodedSignature: nil, - reactions: nil - ) - - override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessage( - db, - id: 123, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data).to(equal(TestApi.data)) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) - } - } - - context("when updating a message") { - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage.write { db in - _ = try Identity - .filter(id: .ed25519PublicKey) - .updateAll(db, Identity.Columns.data.set(to: Data())) - _ = try Identity - .filter(id: .ed25519SecretKey) - .updateAll(db, Identity.Columns.data.set(to: Data())) - } - } - - it("correctly sends the update") { - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("PUT")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) - } - - context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - - it("signs the message correctly") { - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request body - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UpdateMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.UpdateMessageRequest.self, from: requestData!.body!) - - expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestStandardSignature".data(using: .utf8))) - } - - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if there is no user key pair") { - mockStorage.write { db in - _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) - } - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if no signature is generated") { - mockEd25519.reset() // The 'keyPair' value doesn't equate so have to explicitly reset - mockEd25519.when { try $0.sign(data: anyArray(), keyPair: any()) }.thenReturn(nil) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testServer", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - - context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - - it("signs the message correctly") { - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request body - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UpdateMessageRequest = try! JSONDecoder().decode(OpenGroupAPI.UpdateMessageRequest.self, from: requestData!.body!) - - expect(requestBody.data).to(equal("test".data(using: .utf8))) - expect(requestBody.signature).to(equal("TestSogsSignature".data(using: .utf8))) - } - - it("fails to sign if there is no open group") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if there is no ed key pair key") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails to sign if no signature is generated") { - mockSodium - .when { - $0.sogsSignature( - message: anyArray(), - secretKey: anyArray(), - blindedSecretKey: anyArray(), - blindedPublicKey: anyArray() - ) - } - .thenReturn(nil) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageUpdate( - db, - id: 123, - plaintext: "test".data(using: .utf8)!, - fileIds: nil, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - } - - context("when deleting a message") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessageDelete( - db, - id: 123, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("DELETE")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) - } - } - - context("when deleting all messages for a user") { - var response: (info: ResponseInfoType, data: NoResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedMessagesDeleteAll( - db, - sessionId: "testUserId", - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("DELETE")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/all/testUserId")) - } - } - - // MARK: - Pinning - - context("when pinning a message") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedPinMessage( - db, - id: 123, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/pin/123")) - } - } - - context("when unpinning a message") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUnpinMessage( - db, - id: 123, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/123")) - } - } - - context("when unpinning all messages") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - var response: (info: ResponseInfoType, data: NoResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUnpinAll( - db, - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/all")) - } - } - - // MARK: - Files - - context("when uploading files") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: "1")) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUploadFile( - db, - bytes: [], - to: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/file")) - } - - it("doesn't add a fileName to the content-disposition header when not provided") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: "1")) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUploadFile( - db, - bytes: [], - to: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.headers[HTTPHeader.contentDisposition]) - .toNot(contain("filename")) - } - - it("adds the fileName to the content-disposition header when provided") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return try! JSONEncoder().encode(FileUploadResponse(id: "1")) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUploadFile( - db, - bytes: [], - fileName: "TestFileName", - to: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.headers[HTTPHeader.contentDisposition]).to(contain("TestFileName")) - } - } - - context("when downloading files") { - it("generates the request and handles the response correctly") { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return Data() - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedDownloadFile( - db, - fileId: "1", - from: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.urlString).to(equal("testserver/room/testRoom/file/1")) - } - } - - // MARK: - Inbox/Outbox (Message Requests) - - context("when sending message requests") { - var messageData: OpenGroupAPI.SendDirectMessageResponse! - - beforeEach { - class TestApi: TestOnionRequestAPI { - static let data: OpenGroupAPI.SendDirectMessageResponse = OpenGroupAPI.SendDirectMessageResponse( - id: 126, - sender: "testSender", - recipient: "testRecipient", - posted: 321, - expires: 456 - ) - - override class var mockResponse: Data? { return try! JSONEncoder().encode(data) } - } - messageData = TestApi.data - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - messageData = nil - } - - it("correctly sends the message request") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedSend( - db, - ciphertext: "test".data(using: .utf8)!, - toInboxFor: "testUserId", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate the response data - expect(response?.data).to(equal(messageData)) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/inbox/testUserId")) - } - } - - // MARK: - Users - - context("when banning a user") { - var response: (info: ResponseInfoType, data: NoResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserBan( - db, - sessionId: "testUserId", - for: nil, - from: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/user/testUserId/ban")) - } - - it("does a global ban if no room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserBan( - db, - sessionId: "testUserId", - for: nil, - from: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserBanRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beTrue()) - expect(requestBody.rooms).to(beNil()) - } - - it("does room specific bans if room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserBan( - db, - sessionId: "testUserId", - for: nil, - from: ["testRoom"], - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserBanRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beNil()) - expect(requestBody.rooms).to(equal(["testRoom"])) - } - } - - context("when unbanning a user") { - var response: (info: ResponseInfoType, data: NoResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserUnban( - db, - sessionId: "testUserId", - from: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/user/testUserId/unban")) - } - - it("does a global ban if no room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserUnban( - db, - sessionId: "testUserId", - from: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserUnbanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserUnbanRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beTrue()) - expect(requestBody.rooms).to(beNil()) - } - - it("does room specific bans if room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserUnban( - db, - sessionId: "testUserId", - from: ["testRoom"], - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserUnbanRequest = try! JSONDecoder().decode(OpenGroupAPI.UserUnbanRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beNil()) - expect(requestBody.rooms).to(equal(["testRoom"])) - } - } - - context("when updating a users permissions") { - var response: (info: ResponseInfoType, data: NoResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: true, - admin: nil, - visible: true, - for: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/user/testUserId/moderator")) - } - - it("does a global update if no room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: true, - admin: nil, - visible: true, - for: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserModeratorRequest = try! JSONDecoder().decode(OpenGroupAPI.UserModeratorRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beTrue()) - expect(requestBody.rooms).to(beNil()) - } - - it("does room specific updates if room tokens are provided") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: true, - admin: nil, - visible: true, - for: ["testRoom"], - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let requestBody: OpenGroupAPI.UserModeratorRequest = try! JSONDecoder().decode(OpenGroupAPI.UserModeratorRequest.self, from: requestData!.body!) - - expect(requestBody.global).to(beNil()) - expect(requestBody.rooms).to(equal(["testRoom"])) - } - - it("fails if neither moderator or admin are set") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedUserModeratorUpdate( - db, - sessionId: "testUserId", - moderator: nil, - admin: nil, - visible: true, - for: nil, - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.generic.localizedDescription), - timeout: .milliseconds(100) - ) - + + expect(error).to(matchError(OpenGroupAPIError.invalidPreparedData)) expect(response).to(beNil()) } } - - context("when banning and deleting all messages for a user") { - var response: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)? - - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: nil, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: nil, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestApi.self) - } - - afterEach { - response = nil - } - - it("generates the request and handles the response correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( - db, - sessionId: "testUserId", - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.urlString).to(equal("testserver/sequence")) - } - - it("bans the user from the specified room rather than globally") { - mockStorage - .readPublisher { db in - try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( - db, - sessionId: "testUserId", - in: "testRoom", - on: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData - let jsonObject: Any = try! JSONSerialization.jsonObject( - with: requestData!.body!, - options: [.fragmentsAllowed] - ) - let firstJsonObject: Any = ((jsonObject as! [Any]).first as! [String: Any])["json"]! - let firstJsonData: Data = try! JSONSerialization.data(withJSONObject: firstJsonObject) - let firstRequestBody: OpenGroupAPI.UserBanRequest = try! JSONDecoder() - .decode(OpenGroupAPI.UserBanRequest.self, from: firstJsonData) - - expect(firstRequestBody.global).to(beNil()) - expect(firstRequestBody.rooms).to(equal(["testRoom"])) - } - } - - // MARK: - Authentication - - context("when signing") { - beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { - return try! JSONEncoder().encode([OpenGroupAPI.Room]()) - } - } - - dependencies = dependencies.with(onionApi: TestApi.self) - } - - it("fails when there is no userEdKeyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails when there is no serverPublicKey") { - mockStorage.write { db in - _ = try OpenGroup.deleteAll(db) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.noPublicKey.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails when the serverPublicKey is not a hex string") { - mockStorage.write { db in - _ = try OpenGroup.updateAll(db, OpenGroup.Columns.publicKey.set(to: "TestString!!!")) - } - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - context("when unblinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - } - } - - it("signs correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testserver/rooms")) - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) - expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers[HTTPHeader.sogsPubKey]) - .to(equal("00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc")) - expect(requestData?.headers[HTTPHeader.sogsTimestamp]).to(equal("1234567890")) - expect(requestData?.headers[HTTPHeader.sogsNonce]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) - expect(requestData?.headers[HTTPHeader.sogsSignature]).to(equal("TestSignature".bytes.toBase64())) - } - - it("fails when the signature is not generated") { - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - - context("when blinded") { - beforeEach { - mockStorage.write { db in - _ = try Capability.deleteAll(db) - try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) - try Capability(openGroupServer: "testserver", variant: .blind, isMissing: false).insert(db) - } - } - - it("signs correctly") { - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(response) - .toEventuallyNot( - beNil(), - timeout: .milliseconds(100) - ) - expect(error?.localizedDescription).to(beNil()) - - // Validate signature headers - let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.urlString).to(equal("testserver/rooms")) - expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) - expect(requestData?.headers).to(haveCount(4)) - expect(requestData?.headers[HTTPHeader.sogsPubKey]).to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) - expect(requestData?.headers[HTTPHeader.sogsTimestamp]).to(equal("1234567890")) - expect(requestData?.headers[HTTPHeader.sogsNonce]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) - expect(requestData?.headers[HTTPHeader.sogsSignature]).to(equal("TestSogsSignature".bytes.toBase64())) - } - - it("fails when the blindedKeyPair is not generated") { - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } - .thenReturn(nil) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - - it("fails when the sogsSignature is not generated") { - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } - .thenReturn(nil) - - mockStorage - .readPublisher { db in - try OpenGroupAPI - .preparedRooms( - db, - server: "testserver", - using: dependencies - ) - } - .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } - .handleEvents(receiveOutput: { result in response = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error?.localizedDescription) - .toEventually( - equal(OpenGroupAPIError.signingFailed.localizedDescription), - timeout: .milliseconds(100) - ) - - expect(response).to(beNil()) - } - } - } } } } + +// MARK: - Mock Batch Responses + +extension OpenGroupAPI.BatchResponse { + // MARK: - Valid Responses + + static let mockCapabilitiesAndRoomResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.room("testRoom"), OpenGroupAPI.Room.mockBatchSubResponse()) + ] + ) + + static let mockCapabilitiesAndRoomsResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.rooms, [OpenGroupAPI.Room].mockBatchSubResponse()) + ] + ) + + // MARK: - Invalid Responses + + static let mockCapabilitiesAndBanResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.userBan(""), NoResponse.mockBatchSubResponse()) + ] + ) + + static let mockBanAndRoomResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.userBan(""), NoResponse.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.room("testRoom"), OpenGroupAPI.Room.mockBatchSubResponse()) + ] + ) + + static let mockBanAndRoomsResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.userBan(""), NoResponse.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.rooms, [OpenGroupAPI.Room].mockBatchSubResponse()) + ] + ) +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 1a0ab6ace..46ada277e 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -12,77 +12,18 @@ import Nimble @testable import SessionMessagingKit -// MARK: - OpenGroupManagerSpec - class OpenGroupManagerSpec: QuickSpec { - class TestCapabilitiesAndRoomApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( - token: "test", - name: "test", - roomDescription: nil, - infoUpdates: 10, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - // MARK: - Spec override func spec() { - var mockOGMCache: MockOGMCache! - var mockGeneralCache: MockGeneralCache! var mockStorage: Storage! - var mockSodium: MockSodium! - var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! - var mockGenericHash: MockGenericHash! - var mockSign: MockSign! - var mockNonce16Generator: MockNonce16Generator! - var mockNonce24Generator: MockNonce24Generator! + var mockNetwork: MockNetwork! + var mockCrypto: MockCrypto! var mockUserDefaults: MockUserDefaults! - var dependencies: OpenGroupManager.OGMDependencies! + var mockCaches: MockCaches! + var mockGeneralCache: MockGeneralCache! + var mockOGMCache: MockOGMCache! + var dependencies: Dependencies! var disposables: [AnyCancellable] = [] var testInteraction1: Interaction! @@ -99,39 +40,30 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: - Configuration beforeEach { - mockOGMCache = MockOGMCache() - mockGeneralCache = MockGeneralCache() - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, SNMessagingKit.self ] ) - mockSodium = MockSodium() - mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() - mockGenericHash = MockGenericHash() - mockSign = MockSign() - mockNonce16Generator = MockNonce16Generator() - mockNonce24Generator = MockNonce24Generator() + mockNetwork = MockNetwork() + mockCrypto = MockCrypto() mockUserDefaults = MockUserDefaults() - dependencies = OpenGroupManager.OGMDependencies( - subscribeQueue: DispatchQueue.main, - receiveQueue: DispatchQueue.main, - cache: mockOGMCache, - onionApi: TestCapabilitiesAndRoomApi.self, - generalCache: mockGeneralCache, + mockCaches = MockCaches() + mockGeneralCache = MockGeneralCache() + mockOGMCache = MockOGMCache() + dependencies = Dependencies( storage: mockStorage, - sodium: mockSodium, - genericHash: mockGenericHash, - sign: mockSign, - aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, - ed25519: MockEd25519(), - nonceGenerator16: mockNonce16Generator, - nonceGenerator24: mockNonce24Generator, + network: mockNetwork, + crypto: mockCrypto, standardUserDefaults: mockUserDefaults, - date: Date(timeIntervalSince1970: 1234567890) + caches: mockCaches, + dateNow: Date(timeIntervalSince1970: 1234567890), + forceSynchronous: true ) + mockCaches[.general] = mockGeneralCache + mockCaches[.openGroupManager] = mockOGMCache testInteraction1 = Interaction( id: 234, serverHash: "TestServerHash", @@ -169,21 +101,10 @@ class OpenGroupManagerSpec: QuickSpec { infoUpdates: 10, sequenceNumber: 5 ) - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData + details: .mockValue ) testMessage = OpenGroupAPI.Message( id: 127, @@ -234,15 +155,17 @@ class OpenGroupManagerSpec: QuickSpec { try testOpenGroup.insert(db) try Capability(openGroupServer: testOpenGroup.server, variant: .sogs, isMissing: false).insert(db) } - mockOGMCache.when { $0.pendingChanges }.thenReturn([]) - mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(TestConstants.publicKey)") - mockGenericHash.when { $0.hash(message: anyArray(), outputLength: any()) }.thenReturn([]) - mockSodium - .when { [mockGenericHash = mockGenericHash!] sodium in - sodium.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { try $0.perform(.hash(message: anyArray(), outputLength: any())) } + .thenReturn([]) + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -251,24 +174,50 @@ class OpenGroupManagerSpec: QuickSpec { secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) ) - mockSodium + mockCrypto .when { - $0.sogsSignature( - message: anyArray(), - secretKey: anyArray(), - blindedSecretKey: anyArray(), - blindedPublicKey: anyArray() + try $0.perform( + .sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) ) } .thenReturn("TestSogsSignature".bytes) - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn("TestSignature".bytes) - - mockNonce16Generator - .when { $0.nonce() } + mockCrypto + .when { + try $0.perform( + .signature( + message: anyArray(), + secretKey: anyArray() + ) + ) + } + .thenReturn("TestSignature".bytes) + mockCrypto.when { $0.size(.nonce16) }.thenReturn(16) + mockCrypto + .when { try $0.perform(.generateNonce16()) } .thenReturn(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!.bytes) - mockNonce24Generator - .when { $0.nonce() } + mockCrypto.when { $0.size(.nonce24) }.thenReturn(24) + mockCrypto + .when { try $0.perform(.generateNonce24()) } .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) + mockCrypto.when { $0.size(.publicKey) }.thenReturn(32) + mockOGMCache.when { $0.pendingChanges }.thenReturn([]) + mockOGMCache.when { $0.pollers = any() }.thenReturn(()) + mockOGMCache.when { $0.isPolling = any() }.thenReturn(()) + mockOGMCache + .when { $0.defaultRoomsPublisher = any(type: [OpenGroupManager.DefaultRoomInfo].self) } + .thenReturn(()) + mockOGMCache + .when { $0.groupImagePublishers = any(typeA: String.self, typeB: AnyPublisher.self) } + .thenReturn(()) + mockOGMCache + .when { $0.pendingChanges = any(type: OpenGroupAPI.PendingChange.self) } + .thenReturn(()) + mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(TestConstants.publicKey)") cache = OpenGroupManager.Cache() openGroupManager = OpenGroupManager() @@ -280,13 +229,12 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager.shared.stopPolling() // Need to stop any pollers which get created during tests openGroupManager.stopPolling() // Assuming it's different from the above - mockOGMCache = nil mockStorage = nil - mockSodium = nil - mockAeadXChaCha20Poly1305Ietf = nil - mockGenericHash = nil - mockSign = nil + mockCrypto = nil mockUserDefaults = nil + mockCaches = nil + mockGeneralCache = nil + mockOGMCache = nil dependencies = nil disposables = [] @@ -294,12 +242,13 @@ class OpenGroupManagerSpec: QuickSpec { testGroupThread = nil testOpenGroup = nil + cache = nil openGroupManager = nil } - // MARK: - Cache - + // MARK: - cache data context("cache data") { + // MARK: -- defaults the time since last open to greatestFiniteMagnitude it("defaults the time since last open to greatestFiniteMagnitude") { mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in @@ -311,25 +260,27 @@ class OpenGroupManagerSpec: QuickSpec { .to(beCloseTo(.greatestFiniteMagnitude)) } + // MARK: -- returns the time since the last open it("returns the time since the last open") { mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567880)) - dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567890)) + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) expect(cache.getTimeSinceLastOpen(using: dependencies)) .to(beCloseTo(10)) } + // MARK: -- caches the time since the last open it("caches the time since the last open") { mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } .thenReturn(Date(timeIntervalSince1970: 1234567770)) - dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567780)) + dependencies.dateNow = Date(timeIntervalSince1970: 1234567780) expect(cache.getTimeSinceLastOpen(using: dependencies)) .to(beCloseTo(10)) @@ -346,8 +297,7 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - Polling - + // MARK: - when starting polling context("when starting polling") { beforeEach { mockStorage.write { db in @@ -382,43 +332,38 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) } + // MARK: -- creates pollers for all of the open groups it("creates pollers for all of the open groups") { openGroupManager.startPolling(using: dependencies) expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.pollers = [ - "testserver": OpenGroupAPI.Poller(for: "testserver"), - "testserver1": OpenGroupAPI.Poller(for: "testserver1") - ] - }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { + $0.pollers = [ + "testserver": OpenGroupAPI.Poller(for: "testserver"), + "testserver1": OpenGroupAPI.Poller(for: "testserver1") + ] + }) } + // MARK: -- updates the isPolling flag it("updates the isPolling flag") { openGroupManager.startPolling(using: dependencies) expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { $0.isPolling = true }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { $0.isPolling = true }) } + // MARK: -- does nothing if already polling it("does nothing if already polling") { mockOGMCache.when { $0.isPolling }.thenReturn(true) openGroupManager.startPolling(using: dependencies) - expect(mockOGMCache).toEventuallyNot( - call { $0.pollers }, - timeout: .milliseconds(50) - ) + expect(mockOGMCache).toNot(call { $0.pollers }) } } + // MARK: - when stopping polling context("when stopping polling") { beforeEach { mockStorage.write { db in @@ -440,12 +385,14 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) } + // MARK: - removes all pollers it("removes all pollers") { openGroupManager.stopPolling(using: dependencies) expect(mockOGMCache).to(call(matchingParameters: true) { $0.pollers = [:] }) } + // MARK: - updates the isPolling flag it("updates the isPolling flag") { openGroupManager.stopPolling(using: dependencies) @@ -453,76 +400,87 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - Adding & Removing - - // MARK: - --isSessionRunOpenGroup - + // MARK: - when checking if an open group is run by session context("when checking if an open group is run by session") { + // MARK: -- returns false when it does not match one of Sessions servers with no scheme it("returns false when it does not match one of Sessions servers with no scheme") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "test.test")) .to(beFalse()) } + // MARK: -- returns false when it does not match one of Sessions servers in http it("returns false when it does not match one of Sessions servers in http") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://test.test")) .to(beFalse()) } + // MARK: -- returns false when it does not match one of Sessions servers in https it("returns false when it does not match one of Sessions servers in https") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://test.test")) .to(beFalse()) } + // MARK: -- returns true when it matches Sessions SOGS IP it("returns true when it matches Sessions SOGS IP") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS IP with http it("returns true when it matches Sessions SOGS IP with http") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://116.203.70.33")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS IP with https it("returns true when it matches Sessions SOGS IP with https") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://116.203.70.33")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS IP with a port it("returns true when it matches Sessions SOGS IP with a port") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33:80")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS domain it("returns true when it matches Sessions SOGS domain") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS domain with http it("returns true when it matches Sessions SOGS domain with http") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://open.getsession.org")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS domain with https it("returns true when it matches Sessions SOGS domain with https") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://open.getsession.org")) .to(beTrue()) } + // MARK: -- returns true when it matches Sessions SOGS domain with a port it("returns true when it matches Sessions SOGS domain with a port") { expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org:80")) .to(beTrue()) } } - // MARK: - --hasExistingOpenGroup - + // MARK: - when checking it has an existing open group context("when checking it has an existing open group") { + // MARK: -- when there is a thread for the room and the cache has a poller context("when there is a thread for the room and the cache has a poller") { + // MARK: ---- for the no-scheme variant context("for the no-scheme variant") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) } + // MARK: ------ returns true when no scheme is provided it("returns true when no scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -532,12 +490,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a http scheme is provided it("returns true when a http scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -547,12 +506,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a https scheme is provided it("returns true when a https scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -562,18 +522,21 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "https://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } } + // MARK: ---- for the http variant context("for the http variant") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["http://testserver": OpenGroupAPI.Poller(for: "http://testserver")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["http://testserver": OpenGroupAPI.Poller(for: "http://testserver")]) } + // MARK: ------ returns true when no scheme is provided it("returns true when no scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -583,12 +546,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a http scheme is provided it("returns true when a http scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -598,12 +562,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a https scheme is provided it("returns true when a https scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -613,18 +578,21 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "https://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } } + // MARK: ---- for the https variant context("for the https variant") { beforeEach { - mockOGMCache.when { $0.pollers }.thenReturn(["https://testserver": OpenGroupAPI.Poller(for: "https://testserver")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["https://testserver": OpenGroupAPI.Poller(for: "https://testserver")]) } + // MARK: ------ returns true when no scheme is provided it("returns true when no scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -634,12 +602,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a http scheme is provided it("returns true when a http scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -649,12 +618,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } + // MARK: ------ returns true when a https scheme is provided it("returns true when a https scheme is provided") { expect( mockStorage.read { db -> Bool in @@ -664,7 +634,7 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "https://testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) @@ -672,9 +642,12 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- when given the legacy DNS host and there is a cached poller for the default server context("when given the legacy DNS host and there is a cached poller for the default server") { + // MARK: ---- returns true it("returns true") { - mockOGMCache.when { $0.pollers }.thenReturn(["http://116.203.70.33": OpenGroupAPI.Poller(for: "http://116.203.70.33")]) + mockOGMCache.when { $0.pollers } + .thenReturn(["http://116.203.70.33": OpenGroupAPI.Poller(for: "http://116.203.70.33")]) mockStorage.write { db in try SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://116.203.70.33"), @@ -697,14 +670,16 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://open.getsession.org", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } } + // MARK: -- when given the default server and there is a cached poller for the legacy DNS host context("when given the default server and there is a cached poller for the legacy DNS host") { + // MARK: ---- returns true it("returns true") { mockOGMCache.when { $0.pollers }.thenReturn(["http://open.getsession.org": OpenGroupAPI.Poller(for: "http://open.getsession.org")]) mockStorage.write { db in @@ -729,13 +704,14 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "http://116.203.70.33", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beTrue()) } } + // MARK: -- returns false when given an invalid server it("returns false when given an invalid server") { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) @@ -747,12 +723,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "%%%", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beFalse()) } + // MARK: -- returns false if there is not a poller for the server in the cache it("returns false if there is not a poller for the server in the cache") { mockOGMCache.when { $0.pollers }.thenReturn([:]) @@ -764,12 +741,13 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beFalse()) } + // MARK: -- returns false if there is a poller for the server in the cache but no thread for the room it("returns false if there is a poller for the server in the cache but no thread for the room") { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) mockStorage.write { db in @@ -784,21 +762,23 @@ class OpenGroupManagerSpec: QuickSpec { roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - dependencies: dependencies + using: dependencies ) } ).to(beFalse()) } } - // MARK: - --add - + // MARK: - when adding context("when adding") { beforeEach { mockStorage.write { db in try OpenGroup.deleteAll(db) } + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndRoomResponse) mockOGMCache.when { $0.pollers }.thenReturn([:]) mockUserDefaults @@ -808,9 +788,8 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) } + // MARK: -- stores the open group server it("stores the open group server") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage .writePublisher { (db: Database) -> Bool in openGroupManager @@ -820,7 +799,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .flatMap { successfullyAddedGroup in @@ -830,13 +809,11 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage .read { (db: Database) in @@ -846,12 +823,11 @@ class OpenGroupManagerSpec: QuickSpec { .fetchOne(db) } ) - .to(equal(OpenGroup.idFor(roomToken: "testRoom", server: "testServer"))) + .to(equal(OpenGroup.idFor(roomToken: "testRoom", server: "testServer"))) } + // MARK: -- adds a poller it("adds a poller") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage .writePublisher { (db: Database) -> Bool in openGroupManager @@ -861,7 +837,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .flatMap { successfullyAddedGroup in @@ -871,22 +847,18 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.pollers = ["testserver": OpenGroupAPI.Poller(for: "testserver")] - }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { + $0.pollers = ["testserver": OpenGroupAPI.Poller(for: "testserver")] + }) } + // MARK: -- an existing room context("an existing room") { beforeEach { mockOGMCache.when { $0.pollers } @@ -896,9 +868,8 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: ---- does not reset the sequence number or update the public key it("does not reset the sequence number or update the public key") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage .writePublisher { (db: Database) -> Bool in openGroupManager @@ -910,7 +881,7 @@ class OpenGroupManagerSpec: QuickSpec { .replacingOccurrences(of: "c3", with: "00") .replacingOccurrences(of: "b3", with: "00"), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .flatMap { successfullyAddedGroup in @@ -922,13 +893,11 @@ class OpenGroupManagerSpec: QuickSpec { .replacingOccurrences(of: "c3", with: "00") .replacingOccurrences(of: "b3", with: "00"), calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage .read { db in @@ -950,12 +919,12 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- with an invalid response context("with an invalid response") { beforeEach { - class TestApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data() } - } - dependencies = dependencies.with(onionApi: TestApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.response(data: Data())) mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in @@ -964,6 +933,7 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) } + // MARK: ---- fails with the error it("fails with the error") { var error: Error? @@ -976,7 +946,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .flatMap { successfullyAddedGroup in @@ -986,23 +956,18 @@ class OpenGroupManagerSpec: QuickSpec { server: "testServer", publicKey: TestConstants.serverPublicKey, calledFromConfigHandling: true, // Don't trigger SessionUtil logic - dependencies: dependencies + using: dependencies ) } .mapError { result -> Error in error.setting(to: result) } .sinkAndStore(in: &disposables) - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.parsingFailed.localizedDescription), - timeout: .milliseconds(50) - ) + expect(error).to(matchError(HTTPError.parsingFailed)) } } } - // MARK: - --delete - + // MARK: - when deleting context("when deleting") { beforeEach { mockStorage.write { db in @@ -1023,6 +988,7 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.pollers }.thenReturn([:]) } + // MARK: -- removes all interactions for the thread it("removes all interactions for the thread") { mockStorage.write { db in openGroupManager @@ -1038,6 +1004,7 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(0)) } + // MARK: -- removes the given thread it("removes the given thread") { mockStorage.write { db in openGroupManager @@ -1053,7 +1020,9 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(0)) } + // MARK: -- and there is only one open group for this server context("and there is only one open group for this server") { + // MARK: ---- stops the poller it("stops the poller") { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) @@ -1070,6 +1039,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(mockOGMCache).to(call(matchingParameters: true) { $0.pollers = [:] }) } + // MARK: ---- removes the open group it("removes the open group") { mockStorage.write { db in openGroupManager @@ -1086,6 +1056,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- and the are multiple open groups for this server context("and the are multiple open groups for this server") { beforeEach { mockStorage.write { db in @@ -1109,6 +1080,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: ---- removes the open group it("removes the open group") { mockStorage.write { db in openGroupManager @@ -1125,6 +1097,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- and it is the default server context("and it is the default server") { beforeEach { mockStorage.write { db in @@ -1162,6 +1135,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: ---- does not remove the open group it("does not remove the open group") { mockStorage.write { db in openGroupManager @@ -1177,6 +1151,7 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(2)) } + // MARK: ---- deactivates the open group it("deactivates the open group") { mockStorage.write { db in openGroupManager @@ -1201,10 +1176,7 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - Response Processing - - // MARK: - --handleCapabilities - + // MARK: - when handling capabilities context("when handling capabilities") { beforeEach { mockStorage.write { db in @@ -1217,14 +1189,14 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- stores the capabilities it("stores the capabilities") { expect(mockStorage.read { db in try Capability.fetchCount(db) }) .to(equal(1)) } } - // MARK: - --handlePollInfo - + // MARK: - when handling room poll info context("when handling room poll info") { beforeEach { mockStorage.write { db in @@ -1242,9 +1214,8 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(nil) } + // MARK: -- saves the updated open group it("saves the updated open group") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroupManager.handlePollInfo( db, @@ -1252,11 +1223,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + )// { didComplete = true } } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db in try OpenGroup @@ -1267,6 +1237,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(10)) } + // MARK: -- calls the completion block it("calls the completion block") { var didCallComplete: Bool = false @@ -1277,17 +1248,14 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) { didCallComplete = true } } - expect(didCallComplete) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(didCallComplete).to(beTrue()) } + // MARK: -- calls the room image completion block when waiting but there is no image it("calls the room image completion block when waiting but there is no image") { var didCallComplete: Bool = false @@ -1299,17 +1267,14 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies + using: dependencies ) { didCallComplete = true } } - expect(didCallComplete) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(didCallComplete).to(beTrue()) } + // MARK: -- calls the room image completion block when waiting and there is an image it("calls the room image completion block when waiting and there is an image") { var didCallComplete: Bool = false @@ -1341,36 +1306,21 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies + using: dependencies ) { didCallComplete = true } } - expect(didCallComplete) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(didCallComplete).to(beTrue()) } + // MARK: -- and updating the moderator list context("and updating the moderator list") { + // MARK: ---- successfully updates it("successfully updates") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData.with( + details: OpenGroupAPI.Room.mockValue.with( moderators: ["TestMod"], hiddenModerators: [], admins: [], @@ -1385,11 +1335,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> GroupMember? in try GroupMember @@ -1412,24 +1361,12 @@ class OpenGroupManagerSpec: QuickSpec { )) } + // MARK: ---- updates for hidden moderators it("updates for hidden moderators") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData.with( + details: OpenGroupAPI.Room.mockValue.with( moderators: [], hiddenModerators: ["TestMod2"], admins: [], @@ -1444,11 +1381,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> GroupMember? in try GroupMember @@ -1471,24 +1407,11 @@ class OpenGroupManagerSpec: QuickSpec { )) } + // MARK: ---- does not insert mods if no moderators are provided it("does not insert mods if no moderators are provided") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", - activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: nil + activeUsers: 10 ) mockStorage.write { db in @@ -1498,35 +1421,23 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage.read { db -> Int in try GroupMember.fetchCount(db) }) .to(equal(0)) } } + // MARK: -- and updating the admin list context("and updating the admin list") { + // MARK: ---- successfully updates it("successfully updates") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData.with( + details: OpenGroupAPI.Room.mockValue.with( moderators: [], hiddenModerators: [], admins: ["TestAdmin"], @@ -1541,11 +1452,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> GroupMember? in try GroupMember @@ -1568,24 +1478,12 @@ class OpenGroupManagerSpec: QuickSpec { )) } + // MARK: ---- updates for hidden admins it("updates for hidden admins") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: TestCapabilitiesAndRoomApi.roomData.with( + details: OpenGroupAPI.Room.mockValue.with( moderators: [], hiddenModerators: [], admins: [], @@ -1600,11 +1498,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> GroupMember? in try GroupMember @@ -1627,23 +1524,11 @@ class OpenGroupManagerSpec: QuickSpec { )) } + // MARK: ---- does not insert an admin if no admins are provided it("does not insert an admin if no admins are provided") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, details: nil ) @@ -1654,17 +1539,18 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockStorage.read { db -> Int in try GroupMember.fetchCount(db) }) .to(equal(0)) } } + // MARK: -- when it cannot get the open group context("when it cannot get the open group") { + // MARK: ---- does not save the thread it("does not save the thread") { mockStorage.write { db in try OpenGroup.deleteAll(db) @@ -1677,7 +1563,7 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -1685,10 +1571,10 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- when not given a public key context("when not given a public key") { + // MARK: ---- saves the open group with the existing public key it("saves the open group with the existing public key") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroupManager.handlePollInfo( db, @@ -1696,11 +1582,10 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: nil, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -1712,10 +1597,10 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- when checking to start polling context("when checking to start polling") { + // MARK: ---- starts a new poller when not already polling it("starts a new poller when not already polling") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.pollers }.thenReturn([:]) mockStorage.write { db in @@ -1725,20 +1610,18 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) .to(call(matchingParameters: true) { $0.pollers = ["testserver": OpenGroupAPI.Poller(for: "testserver")] }) } + // MARK: ---- does not start a new poller when already polling it("does not start a new poller when already polling") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) mockStorage.write { db in @@ -1748,15 +1631,15 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.publicKey, for: "testRoom", on: "testServer", - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache).to(call(.exactly(times: 1)) { $0.pollers }) } } + // MARK: -- when trying to get the room image context("when trying to get the room image") { beforeEach { let image: UIImage = UIImage(color: .red, size: CGSize(width: 1, height: 1)) @@ -1773,49 +1656,15 @@ class OpenGroupManagerSpec: QuickSpec { ]) } + // MARK: ---- uses the provided room image id if available it("uses the provided room image id if available") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: OpenGroupAPI.Room( + details: OpenGroupAPI.Room.mockValue.with( token: "test", name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "10", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + imageId: "10" ) ) @@ -1827,11 +1676,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -1850,9 +1698,8 @@ class OpenGroupManagerSpec: QuickSpec { ).toNot(beNil()) } + // MARK: ---- uses the existing room image id if none is provided it("uses the existing room image id if none is provided") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroup.deleteAll(db) try OpenGroup( @@ -1868,20 +1715,9 @@ class OpenGroupManagerSpec: QuickSpec { ).insert(db) } - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, details: nil ) @@ -1893,11 +1729,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -1916,9 +1751,8 @@ class OpenGroupManagerSpec: QuickSpec { ).toNot(beNil()) } + // MARK: ---- uses the new room image id if there is an existing one it("uses the new room image id if there is an existing one") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroup.deleteAll(db) try OpenGroup( @@ -1934,46 +1768,14 @@ class OpenGroupManagerSpec: QuickSpec { ).insert(db) } - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: OpenGroupAPI.Room( + details: OpenGroupAPI.Room.mockValue.with( token: "test", name: "test", - roomDescription: nil, infoUpdates: 10, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "10", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + imageId: "10" ) ) @@ -1985,11 +1787,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -2006,16 +1807,11 @@ class OpenGroupManagerSpec: QuickSpec { .fetchOne(db) } ).toNot(beNil()) - expect(mockOGMCache) - .toEventually( - call(.exactly(times: 1)) { $0.groupImagePublishers }, - timeout: .milliseconds(50) - ) + expect(mockOGMCache).to(call(.exactly(times: 1)) { $0.groupImagePublishers }) } + // MARK: ---- does nothing if there is no room image it("does nothing if there is no room image") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockStorage.write { db in try OpenGroupManager.handlePollInfo( db, @@ -2024,11 +1820,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -2039,54 +1834,20 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beNil()) } + // MARK: ---- does nothing if it fails to retrieve the room image it("does nothing if it fails to retrieve the room image") { - var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.groupImagePublishers } .thenReturn([ OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Fail(error: HTTPError.generic).eraseToAnyPublisher() ]) - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: OpenGroupAPI.Room( + details: OpenGroupAPI.Room.mockValue.with( token: "test", name: "test", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "10", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + imageId: "10" ) ) @@ -2098,11 +1859,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -2113,49 +1873,16 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beNil()) } + // MARK: ---- saves the retrieved room image it("saves the retrieved room image") { - var didComplete: Bool = false // Prevent multi-threading test bugs - - testPollInfo = OpenGroupAPI.RoomPollInfo( + testPollInfo = OpenGroupAPI.RoomPollInfo.mockValue.with( token: "testRoom", activeUsers: 10, - admin: false, - globalAdmin: false, - moderator: false, - globalModerator: false, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil, - details: OpenGroupAPI.Room( + details: OpenGroupAPI.Room.mockValue.with( token: "test", name: "test", - roomDescription: nil, infoUpdates: 10, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "10", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + imageId: "10" ) ) mockStorage.write { db in @@ -2166,11 +1893,10 @@ class OpenGroupManagerSpec: QuickSpec { for: "testRoom", on: "testServer", waitForImageToComplete: true, - dependencies: dependencies - ) { didComplete = true } + using: dependencies + ) } - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -2183,8 +1909,7 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - --handleMessages - + // MARK: - when handling messages context("when handling messages") { beforeEach { mockStorage.write { db in @@ -2194,6 +1919,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- updates the sequence number when there are messages it("updates the sequence number when there are messages") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2216,7 +1942,7 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2230,6 +1956,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(124)) } + // MARK: -- does not update the sequence number if there are no messages it("does not update the sequence number if there are no messages") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2237,7 +1964,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2251,6 +1978,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(5)) } + // MARK: -- ignores a message with no sender it("ignores a message with no sender") { mockStorage.write { db in try Interaction.deleteAll(db) @@ -2277,13 +2005,14 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: -- ignores a message with invalid data it("ignores a message with invalid data") { mockStorage.write { db in try Interaction.deleteAll(db) @@ -2310,13 +2039,14 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: -- processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2324,13 +2054,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testMessage], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } + // MARK: -- processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2354,14 +2085,16 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } + // MARK: -- with no data context("with no data") { + // MARK: ---- deletes the message if we have the message it("deletes the message if we have the message") { mockStorage.write { db in try Interaction @@ -2392,13 +2125,14 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: ---- does nothing if we do not have the message it("does nothing if we do not have the message") { mockStorage.write { db in OpenGroupManager.handleMessages( @@ -2421,7 +2155,7 @@ class OpenGroupManagerSpec: QuickSpec { ], for: "testRoom", on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2430,41 +2164,49 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - --handleDirectMessages - + // MARK: - when handling direct messages context("when handling direct messages") { beforeEach { - mockSodium - .when { - $0.sharedBlindedEncryptionKey( - secretKey: anyArray(), - otherBlindedPublicKey: anyArray(), - fromBlindedPublicKey: anyArray(), - toBlindedPublicKey: anyArray(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) ) } .thenReturn([]) - mockSodium - .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .generateBlindingFactor(serverPublicKey: any(), using: dependencies) + ) + } .thenReturn([]) - mockAeadXChaCha20Poly1305Ietf + mockCrypto .when { - $0.decrypt( - authenticatedCipherText: anyArray(), - secretKey: anyArray(), - nonce: anyArray() + try $0.perform( + .decryptAeadXChaCha20( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray() + ) ) } .thenReturn( Data(base64Encoded:"ChQKC1Rlc3RNZXNzYWdlONCI7I/3Iw==")!.bytes + [UInt8](repeating: 0, count: 32) ) - mockSign - .when { $0.toX25519(ed25519PublicKey: anyArray()) } + mockCrypto + .when { try $0.perform(.toX25519(ed25519PublicKey: anyArray())) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) } + // MARK: -- does nothing if there are no messages it("does nothing if there are no messages") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2472,7 +2214,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2494,6 +2236,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(0)) } + // MARK: -- does nothing if it cannot get the open group it("does nothing if it cannot get the open group") { mockStorage.write { db in try OpenGroup.deleteAll(db) @@ -2505,7 +2248,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2527,6 +2270,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beNil()) } + // MARK: -- ignores messages with non base64 encoded data it("ignores messages with non base64 encoded data") { testDirectMessage = OpenGroupAPI.DirectMessage( id: testDirectMessage.id, @@ -2543,24 +2287,34 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: -- for the inbox context("for the inbox") { beforeEach { - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(Data(hex: testDirectMessage.sender.removingIdPrefixIfNeeded()).bytes) - - mockSodium - .when { $0.sessionId(any(), matchesBlindedId: any(), serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.verify( + .sessionId( + any(), + matchesBlindedId: any(), + serverPublicKey: any(), + using: dependencies + ) + ) + } .thenReturn(false) } + // MARK: ---- updates the inbox latest message id it("updates the inbox latest message id") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2568,7 +2322,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2582,6 +2336,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(128)) } + // MARK: ---- ignores a message with invalid data it("ignores a message with invalid data") { testDirectMessage = OpenGroupAPI.DirectMessage( id: testDirectMessage.id, @@ -2598,13 +2353,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } + // MARK: ---- processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2612,13 +2368,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } + // MARK: ---- processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2636,7 +2393,7 @@ class OpenGroupManagerSpec: QuickSpec { ], fromOutbox: false, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2644,17 +2401,27 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- for the outbox context("for the outbox") { beforeEach { - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(Data(hex: testDirectMessage.recipient.removingIdPrefixIfNeeded()).bytes) - - mockSodium - .when { $0.sessionId(any(), matchesBlindedId: any(), serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.verify( + .sessionId( + any(), + matchesBlindedId: any(), + serverPublicKey: any(), + using: dependencies + ) + ) + } .thenReturn(false) } + // MARK: ---- updates the outbox latest message id it("updates the outbox latest message id") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2662,7 +2429,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2676,6 +2443,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(equal(128)) } + // MARK: ---- retrieves an existing blinded id lookup it("retrieves an existing blinded id lookup") { mockStorage.write { db in try BlindedIdLookup( @@ -2692,7 +2460,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2700,6 +2468,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(2)) } + // MARK: ---- falls back to using the blinded id if no lookup is found it("falls back to using the blinded id if no lookup is found") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2707,7 +2476,7 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2728,6 +2497,7 @@ class OpenGroupManagerSpec: QuickSpec { ).toNot(beNil()) } + // MARK: ---- ignores a message with invalid data it("ignores a message with invalid data") { testDirectMessage = OpenGroupAPI.DirectMessage( id: testDirectMessage.id, @@ -2744,13 +2514,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(1)) } + // MARK: ---- processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2758,13 +2529,14 @@ class OpenGroupManagerSpec: QuickSpec { messages: [testDirectMessage], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(2)) } + // MARK: ---- processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in OpenGroupManager.handleDirectMessages( @@ -2782,7 +2554,7 @@ class OpenGroupManagerSpec: QuickSpec { ], fromOutbox: true, on: "testServer", - dependencies: dependencies + using: dependencies ) } @@ -2791,10 +2563,7 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - Convenience - - // MARK: - --isUserModeratorOrAdmin - + // MARK: - when determining if a user is a moderator or an admin context("when determining if a user is a moderator or an admin") { beforeEach { mockStorage.write { db in @@ -2802,6 +2571,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- uses an empty set for moderators by default it("uses an empty set for moderators by default") { expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -2813,6 +2583,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: -- uses an empty set for admins by default it("uses an empty set for admins by default") { expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -2824,6 +2595,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: -- returns true if the key is in the moderator set it("returns true if the key is in the moderator set") { mockStorage.write { db in try GroupMember( @@ -2844,6 +2616,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: -- returns true if the key is in the admin set it("returns true if the key is in the admin set") { mockStorage.write { db in try GroupMember( @@ -2864,6 +2637,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: -- returns true if the moderator is hidden it("returns true if the moderator is hidden") { mockStorage.write { db in try GroupMember( @@ -2884,6 +2658,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: -- returns true if the admin is hidden it("returns true if the admin is hidden") { mockStorage.write { db in try GroupMember( @@ -2904,6 +2679,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: -- returns false if the key is not a valid session id it("returns false if the key is not a valid session id") { expect( OpenGroupManager.isUserModeratorOrAdmin( @@ -2915,7 +2691,9 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: -- and the key is a standard session id context("and the key is a standard session id") { + // MARK: ---- returns false if the key is not the users session id it("returns false if the key is not the users session id") { mockStorage.write { db in let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") @@ -2934,6 +2712,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns true if the key is the current users and the users unblinded id is a moderator or admin it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { mockStorage.write { db in let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") @@ -2959,14 +2738,17 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: ---- returns true if the key is the current users and the users blinded id is a moderator or admin it("returns true if the key is the current users and the users blinded id is a moderator or admin") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -2995,7 +2777,9 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- and the key is unblinded context("and the key is unblinded") { + // MARK: ---- returns false if unable to retrieve the user ed25519 key it("returns false if unable to retrieve the user ed25519 key") { mockStorage.write { db in try Identity.filter(id: .ed25519PublicKey).deleteAll(db) @@ -3012,6 +2796,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns false if the key is not the users unblinded id it("returns false if the key is not the users unblinded id") { mockStorage.write { db in let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") @@ -3030,6 +2815,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns true if the key is the current users and the users session id is a moderator or admin it("returns true if the key is the current users and the users session id is a moderator or admin") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") @@ -3057,14 +2843,17 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: ---- returns true if the key is the current users and the users blinded id is a moderator or admin it("returns true if the key is the current users and the users blinded id is a moderator or admin") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -3098,7 +2887,9 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- and the key is blinded context("and the key is blinded") { + // MARK: ---- returns false if unable to retrieve the user ed25519 key it("returns false if unable to retrieve the user ed25519 key") { mockStorage.write { db in try Identity.filter(id: .ed25519PublicKey).deleteAll(db) @@ -3115,13 +2906,16 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns false if unable generate a blinded key it("returns false if unable generate a blinded key") { - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn(nil) @@ -3136,14 +2930,17 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns false if the key is not the users blinded id it("returns false if the key is not the users blinded id") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -3163,15 +2960,18 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: ---- returns true if the key is the current users and the users session id is a moderator or admin it("returns true if the key is the current users and the users session id is a moderator or admin") { let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -3204,13 +3004,16 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beTrue()) } + // MARK: ---- returns true if the key is the current users and the users unblinded id is a moderator or admin it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn( @@ -3247,67 +3050,12 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: - --getDefaultRoomsIfNeeded - + // MARK: - when getting the default rooms if needed context("when getting the default rooms if needed") { beforeEach { - class TestRoomsApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomsData: [OpenGroupAPI.Room] = [ - TestCapabilitiesAndRoomApi.roomData, - OpenGroupAPI.Room( - token: "test2", - name: "test2", - roomDescription: nil, - infoUpdates: 11, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "12", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - ] - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomsData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) - } - } - dependencies = dependencies.with(onionApi: TestRoomsApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(OpenGroupAPI.BatchResponse.mockCapabilitiesAndRoomsResponse) mockStorage.write { db in try OpenGroup.deleteAll(db) @@ -3335,6 +3083,7 @@ class OpenGroupManagerSpec: QuickSpec { }.thenReturn(()) } + // MARK: -- caches the publisher if there is no cached publisher it("caches the publisher if there is no cached publisher") { let publisher = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) @@ -3344,33 +3093,11 @@ class OpenGroupManagerSpec: QuickSpec { }) } + // MARK: -- returns the cached publisher if there is one it("returns the cached publisher if there is one") { - let uniqueRoomInstance: OpenGroupAPI.Room = OpenGroupAPI.Room( + let uniqueRoomInstance: OpenGroupAPI.Room = OpenGroupAPI.Room.mockValue.with( token: "UniqueId", - name: "", - roomDescription: nil, - infoUpdates: 0, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: nil, - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil + name: "" ) let publisher = Future<[OpenGroupManager.DefaultRoomInfo], Error> { resolver in resolver(Result.success([(uniqueRoomInstance, nil)])) @@ -3384,10 +3111,13 @@ class OpenGroupManagerSpec: QuickSpec { .to(equal(publisher.firstValue()?.map { $0.room })) } + // MARK: -- stores the open group information it("stores the open group information") { OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - expect(mockStorage.read { db -> Int in try OpenGroup.fetchCount(db) }).to(equal(1)) + // 1 for the value returned from the API and 1 for the default added + // by the 'RetrieveDefaultOpenGroupRoomsJob' logic + expect(mockStorage.read { db -> Int in try OpenGroup.fetchCount(db) }).to(equal(2)) expect( mockStorage.read { db -> String? in try OpenGroup @@ -3414,6 +3144,7 @@ class OpenGroupManagerSpec: QuickSpec { ).to(beFalse()) } + // MARK: -- fetches rooms for the server it("fetches rooms for the server") { var response: [OpenGroupManager.DefaultRoomInfo]? @@ -3422,53 +3153,14 @@ class OpenGroupManagerSpec: QuickSpec { .sinkAndStore(in: &disposables) expect(response?.map { $0.room }) - .toEventually( - equal( - [ - TestCapabilitiesAndRoomApi.roomData, - OpenGroupAPI.Room( - token: "test2", - name: "test2", - roomDescription: nil, - infoUpdates: 11, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "12", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([OpenGroupAPI.Room.mockValue])) } + // MARK: -- will retry fetching rooms 8 times before it fails it("will retry fetching rooms 8 times before it fails") { - class TestRoomsApi: TestOnionRequestAPI { - static var callCounter: Int = 0 - - override class var mockResponse: Data? { - callCounter += 1 - return nil - } - } - dependencies = dependencies.with(onionApi: TestRoomsApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.nullResponse()) var error: Error? @@ -3476,19 +3168,16 @@ class OpenGroupManagerSpec: QuickSpec { .mapError { result -> Error in error.setting(to: result) } .sinkAndStore(in: &disposables) - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.invalidResponse.localizedDescription), - timeout: .milliseconds(50) - ) - expect(TestRoomsApi.callCounter).to(equal(9)) // First attempt + 8 retries + expect(error).to(matchError(HTTPError.parsingFailed)) + expect(mockNetwork) // First attempt + 8 retries + .to(call(.exactly(times: 9)) { $0.send(.onionRequest(any(), to: any(), with: any())) }) } + // MARK: -- removes the cache publisher if all retries fail it("removes the cache publisher if all retries fail") { - class TestRoomsApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return nil } - } - dependencies = dependencies.with(onionApi: TestRoomsApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.nullResponse()) var error: Error? @@ -3496,93 +3185,69 @@ class OpenGroupManagerSpec: QuickSpec { .mapError { result -> Error in error.setting(to: result) } .sinkAndStore(in: &disposables) - expect(error?.localizedDescription) - .toEventually( - equal(HTTPError.invalidResponse.localizedDescription), - timeout: .milliseconds(50) - ) + expect(error) + .to(matchError(HTTPError.parsingFailed)) expect(mockOGMCache) .to(call(matchingParameters: true) { $0.defaultRoomsPublisher = nil }) } + // MARK: -- fetches the image for any rooms with images it("fetches the image for any rooms with images") { - class TestRoomsApi: TestOnionRequestAPI { - static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) - static let roomsData: [OpenGroupAPI.Room] = [ - OpenGroupAPI.Room( - token: "test2", - name: "test2", - roomDescription: nil, - infoUpdates: 11, - messageSequence: 0, - created: 0, - activeUsers: 0, - activeUsersCutoff: 0, - imageId: "12", - pinnedMessages: nil, - admin: false, - globalAdmin: false, - admins: [], - hiddenAdmins: nil, - moderator: false, - globalModerator: false, - moderators: [], - hiddenModerators: nil, - read: false, - defaultRead: nil, - defaultAccessible: nil, - write: false, - defaultWrite: nil, - upload: false, - defaultUpload: nil - ) - ] - - override class var mockResponse: Data? { - let responses: [Data] = [ - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: capabilitiesData, - failedToParseBody: false - ) - ), - try! JSONEncoder().encode( - HTTP.BatchSubResponse( - code: 200, - headers: [:], - body: roomsData, - failedToParseBody: false - ) - ) - ] - - return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + mockNetwork + .when { + $0.send(.onionRequest( + URLRequest(url: URL(string: "https://open.getsession.org/sequence")!), + to: OpenGroupAPI.defaultServer, + with: OpenGroupAPI.defaultServerPublicKey + )) } - } + .thenReturn( + MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + ( + OpenGroupAPI.Endpoint.rooms, + [ + OpenGroupAPI.Room.mockValue.with( + token: "test2", + name: "test2", + infoUpdates: 11, + imageId: "12" + ) + ].batchSubResponse() + ) + ] + ) + ) + mockNetwork + .when { + $0.send( + .onionRequest( + URLRequest(url: URL(string: "https://open.getsession.org/room/test2/file/12")!), + to: OpenGroupAPI.defaultServer, + with: OpenGroupAPI.defaultServerPublicKey, + timeout: FileServerAPI.fileDownloadTimeout + ) + ) + } + .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) + let testDate: Date = Date(timeIntervalSince1970: 1234567890) - dependencies = dependencies.with( - onionApi: TestRoomsApi.self, - date: testDate - ) + dependencies.dateNow = testDate OpenGroupManager .getDefaultRoomsIfNeeded(using: dependencies) .sinkAndStore(in: &disposables) expect(mockUserDefaults) - .toEventually( - call(matchingParameters: true) { - $0.set( - testDate, - forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue - ) - }, - timeout: .milliseconds(100) - ) + .to(call(matchingParameters: true) { + $0.set( + testDate, + forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue + ) + }) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -3591,25 +3256,23 @@ class OpenGroupManagerSpec: QuickSpec { .asRequest(of: Data.self) .fetchOne(db) } - ).to(equal(TestRoomsApi.mockResponse!)) + ).to(equal(Data([1, 2, 3]))) } } - // MARK: - --roomImage - + // MARK: - when getting a room image context("when getting a room image") { beforeEach { - class TestImageApi: TestOnionRequestAPI { - override class var mockResponse: Data? { return Data([1, 2, 3]) } - } - dependencies = dependencies.with(onionApi: TestImageApi.self) + mockNetwork + .when { $0.send(.onionRequest(any(), to: any(), with: any())) } + .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) - mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.object(forKey: any()) - }.thenReturn(nil) - mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.set(anyAny(), forKey: any()) - }.thenReturn(()) + mockUserDefaults + .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.object(forKey: any()) } + .thenReturn(nil) + mockUserDefaults + .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.set(anyAny(), forKey: any()) } + .thenReturn(()) mockOGMCache.when { $0.groupImagePublishers }.thenReturn([:]) mockStorage.write { db in @@ -3626,6 +3289,7 @@ class OpenGroupManagerSpec: QuickSpec { } } + // MARK: -- retrieves the image retrieval publisher from the cache if it exists it("retrieves the image retrieval publisher from the cache if it exists") { let publisher = Future { resolver in resolver(Result.success(Data([5, 4, 3, 2, 1]))) @@ -3648,11 +3312,11 @@ class OpenGroupManagerSpec: QuickSpec { .handleEvents(receiveOutput: { result = $0 }) .sinkAndStore(in: &disposables) - expect(result).toEventually(equal(publisher.firstValue()), timeout: .milliseconds(50)) + expect(result).to(equal(publisher.firstValue())) } + // MARK: -- does not save the fetched image to storage it("does not save the fetched image to storage") { - var didComplete: Bool = false OpenGroupManager .roomImage( fileId: "1", @@ -3661,10 +3325,8 @@ class OpenGroupManagerSpec: QuickSpec { existingData: nil, using: dependencies ) - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -3673,14 +3335,11 @@ class OpenGroupManagerSpec: QuickSpec { .asRequest(of: Data.self) .fetchOne(db) } - ).toEventually( - beNil(), - timeout: .milliseconds(50) - ) + ).to(beNil()) } + // MARK: -- does not update the image update timestamp it("does not update the image update timestamp") { - var didComplete: Bool = false OpenGroupManager .roomImage( fileId: "1", @@ -3689,37 +3348,19 @@ class OpenGroupManagerSpec: QuickSpec { existingData: nil, using: dependencies ) - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockUserDefaults) - .toEventuallyNot( - call(matchingParameters: true) { - $0.set( - dependencies.date, - forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue - ) - }, - timeout: .milliseconds(50) - ) + .toNot(call(matchingParameters: true) { + $0.set( + dependencies.dateNow, + forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue + ) + }) } + // MARK: -- adds the image retrieval publisher to the cache it("adds the image retrieval publisher to the cache") { - class TestNeverReturningApi: OnionRequestAPIType { - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return Future<(ResponseInfoType, Data?), Error> { _ in }.eraseToAnyPublisher() - } - - static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return Just(Data()) - .setFailureType(to: Error.self) - .map { data in (HTTP.ResponseInfo(code: 0, headers: [:]), data) } - .eraseToAnyPublisher() - } - } - dependencies = dependencies.with(onionApi: TestNeverReturningApi.self) - let publisher = OpenGroupManager .roomImage( fileId: "1", @@ -3731,15 +3372,14 @@ class OpenGroupManagerSpec: QuickSpec { publisher.sinkAndStore(in: &disposables) expect(mockOGMCache) - .toEventually( - call(matchingParameters: true) { - $0.groupImagePublishers = [OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): publisher] - }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { + $0.groupImagePublishers = [OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): publisher] + }) } + // MARK: -- for the default server context("for the default server") { + // MARK: ---- fetches a new image if there is no cached one it("fetches a new image if there is no cached one") { var result: Data? @@ -3754,12 +3394,11 @@ class OpenGroupManagerSpec: QuickSpec { .handleEvents(receiveOutput: { (data: Data) in result = data }) .sinkAndStore(in: &disposables) - expect(result).toEventually(equal(Data([1, 2, 3])), timeout: .milliseconds(50)) + expect(result).to(equal(Data([1, 2, 3]))) } + // MARK: ---- saves the fetched image to storage it("saves the fetched image to storage") { - var didComplete: Bool = false - OpenGroupManager .roomImage( fileId: "1", @@ -3768,10 +3407,8 @@ class OpenGroupManagerSpec: QuickSpec { existingData: nil, using: dependencies ) - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage.read { db -> Data? in try OpenGroup @@ -3780,15 +3417,11 @@ class OpenGroupManagerSpec: QuickSpec { .asRequest(of: Data.self) .fetchOne(db) } - ).toEventuallyNot( - beNil(), - timeout: .milliseconds(50) - ) + ).toNot(beNil()) } + // MARK: ---- updates the image update timestamp it("updates the image update timestamp") { - var didComplete: Bool = false - OpenGroupManager .roomImage( fileId: "1", @@ -3797,30 +3430,26 @@ class OpenGroupManagerSpec: QuickSpec { existingData: nil, using: dependencies ) - .handleEvents(receiveCompletion: { _ in didComplete = true }) .sinkAndStore(in: &disposables) - expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockUserDefaults) - .toEventually( - call(matchingParameters: true) { - $0.set( - dependencies.date, - forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue - ) - }, - timeout: .milliseconds(50) - ) + .to(call(matchingParameters: true) { + $0.set( + dependencies.dateNow, + forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue + ) + }) } + // MARK: ---- and there is a cached image context("and there is a cached image") { beforeEach { - dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567890)) + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in defaults.object(forKey: any()) } - .thenReturn(dependencies.date) + .thenReturn(dependencies.dateNow) mockStorage.write(updates: { db in try OpenGroup .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer)) @@ -3831,6 +3460,7 @@ class OpenGroupManagerSpec: QuickSpec { }) } + // MARK: ------ retrieves the cached image it("retrieves the cached image") { var result: Data? @@ -3845,13 +3475,14 @@ class OpenGroupManagerSpec: QuickSpec { .handleEvents(receiveOutput: { (data: Data) in result = data }) .sinkAndStore(in: &disposables) - expect(result).toEventually(equal(Data([2, 3, 4])), timeout: .milliseconds(50)) + expect(result).to(equal(Data([2, 3, 4]))) } + // MARK: ------ fetches a new image if the cached on is older than a week it("fetches a new image if the cached on is older than a week") { let weekInSeconds: TimeInterval = (7 * 24 * 60 * 60) let targetTimestamp: TimeInterval = ( - dependencies.date.timeIntervalSince1970 - weekInSeconds - 1 + dependencies.dateNow.timeIntervalSince1970 - weekInSeconds - 1 ) mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in @@ -3872,7 +3503,7 @@ class OpenGroupManagerSpec: QuickSpec { .handleEvents(receiveOutput: { (data: Data) in result = data }) .sinkAndStore(in: &disposables) - expect(result).toEventually(equal(Data([1, 2, 3])), timeout: .milliseconds(50)) + expect(result).to(equal(Data([1, 2, 3]))) } } } @@ -3881,34 +3512,38 @@ class OpenGroupManagerSpec: QuickSpec { } } -// MARK: - Room Convenience Extensions +// MARK: - Convenience Extensions extension OpenGroupAPI.Room { func with( - moderators: [String], - hiddenModerators: [String], - admins: [String], - hiddenAdmins: [String] + token: String? = nil, + name: String? = nil, + infoUpdates: Int64? = nil, + imageId: String? = nil, + moderators: [String]? = nil, + hiddenModerators: [String]? = nil, + admins: [String]? = nil, + hiddenAdmins: [String]? = nil ) -> OpenGroupAPI.Room { return OpenGroupAPI.Room( - token: self.token, - name: self.name, + token: (token ?? self.token), + name: (name ?? self.name), roomDescription: self.roomDescription, - infoUpdates: self.infoUpdates, + infoUpdates: (infoUpdates ?? self.infoUpdates), messageSequence: self.messageSequence, created: self.created, activeUsers: self.activeUsers, activeUsersCutoff: self.activeUsersCutoff, - imageId: self.imageId, + imageId: (imageId ?? self.imageId), pinnedMessages: self.pinnedMessages, admin: self.admin, globalAdmin: self.globalAdmin, - admins: admins, - hiddenAdmins: hiddenAdmins, + admins: (admins ?? self.admins), + hiddenAdmins: (hiddenAdmins ?? self.hiddenAdmins), moderator: self.moderator, globalModerator: self.globalModerator, - moderators: moderators, - hiddenModerators: hiddenModerators, + moderators: (moderators ?? self.moderators), + hiddenModerators: (hiddenModerators ?? self.hiddenModerators), read: self.read, defaultRead: self.defaultRead, defaultAccessible: self.defaultAccessible, @@ -3919,3 +3554,160 @@ extension OpenGroupAPI.Room { ) } } + +extension OpenGroupAPI.RoomPollInfo { + func with( + token: String? = nil, + activeUsers: Int64? = nil, + details: OpenGroupAPI.Room? = .mockValue + ) -> OpenGroupAPI.RoomPollInfo { + return OpenGroupAPI.RoomPollInfo( + token: (token ?? self.token), + activeUsers: (activeUsers ?? self.activeUsers), + admin: self.admin, + globalAdmin: self.globalAdmin, + moderator: self.moderator, + globalModerator: self.globalModerator, + read: self.read, + defaultRead: self.defaultRead, + defaultAccessible: self.defaultAccessible, + write: self.write, + defaultWrite: self.defaultWrite, + upload: self.upload, + defaultUpload: self.defaultUpload, + details: details + ) + } +} + +// MARK: - Mock Types + +extension OpenGroupAPI.Capabilities: Mocked { + static var mockValue: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [], missing: nil) +} + +extension OpenGroupAPI.Room: Mocked { + static var mockValue: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "test", + name: "testRoom", + roomDescription: nil, + infoUpdates: 1, + messageSequence: 1, + created: 1, + activeUsers: 1, + activeUsersCutoff: 1, + imageId: nil, + pinnedMessages: nil, + admin: false, + globalAdmin: false, + admins: [], + hiddenAdmins: nil, + moderator: false, + globalModerator: false, + moderators: [], + hiddenModerators: nil, + read: true, + defaultRead: nil, + defaultAccessible: nil, + write: true, + defaultWrite: nil, + upload: true, + defaultUpload: nil + ) +} + +extension OpenGroupAPI.RoomPollInfo: Mocked { + static var mockValue: OpenGroupAPI.RoomPollInfo = OpenGroupAPI.RoomPollInfo( + token: "test", + activeUsers: 1, + admin: false, + globalAdmin: false, + moderator: false, + globalModerator: false, + read: true, + defaultRead: nil, + defaultAccessible: nil, + write: true, + defaultWrite: nil, + upload: true, + defaultUpload: false, + details: .mockValue + ) +} + +extension OpenGroupAPI.Message: Mocked { + static var mockValue: OpenGroupAPI.Message = OpenGroupAPI.Message( + id: 100, + sender: TestConstants.blindedPublicKey, + posted: 1, + edited: nil, + deleted: nil, + seqNo: 1, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: nil, + base64EncodedSignature: nil, + reactions: nil + ) +} + +extension OpenGroupAPI.SendDirectMessageResponse: Mocked { + static var mockValue: OpenGroupAPI.SendDirectMessageResponse = OpenGroupAPI.SendDirectMessageResponse( + id: 1, + sender: TestConstants.blindedPublicKey, + recipient: "testRecipient", + posted: 1122, + expires: 2233 + ) +} + +extension OpenGroupAPI.DirectMessage: Mocked { + static var mockValue: OpenGroupAPI.DirectMessage = OpenGroupAPI.DirectMessage( + id: 101, + sender: TestConstants.blindedPublicKey, + recipient: "testRecipient", + posted: 1212, + expires: 2323, + base64EncodedMessage: "TestMessage".data(using: .utf8)!.base64EncodedString() + ) +} + +extension OpenGroupAPI.BatchResponse { + static let mockUnblindedPollResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomPollInfo("testRoom", 0), OpenGroupAPI.RoomPollInfo.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomMessagesRecent("testRoom"), [OpenGroupAPI.Message].mockBatchSubResponse()) + ] + ) + + static let mockBlindedPollResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomPollInfo("testRoom", 0), OpenGroupAPI.RoomPollInfo.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomMessagesRecent("testRoom"), OpenGroupAPI.Message.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.inboxSince(id: 0), OpenGroupAPI.DirectMessage.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.outboxSince(id: 0), OpenGroupAPI.DirectMessage.self.mockBatchSubResponse()) + ] + ) + + static let mockCapabilitiesResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()) + ] + ) + + static let mockRoomResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Room.mockBatchSubResponse()) + ] + ) + + static let mockBanAndDeleteAllResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.userBan(""), NoResponse.mockBatchSubResponse()), + (OpenGroupAPI.Endpoint.roomDeleteMessages("testRoon", sessionId: ""), NoResponse.mockBatchSubResponse()) + ] + ) +} diff --git a/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift deleted file mode 100644 index f27eee889..000000000 --- a/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -import Quick -import Nimble - -@testable import SessionMessagingKit - -class SodiumProtocolsSpec: QuickSpec { - // MARK: - Spec - - override func spec() { - describe("an AeadXChaCha20Poly1305IetfType") { - let testValue: [UInt8] = [1, 2, 3] - - it("provides the default values in it's extensions") { - let mockAead: MockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() - mockAead - .when { - $0.encrypt( - message: anyArray(), - secretKey: anyArray(), - nonce: anyArray(), - additionalData: anyArray() - ) - } - .thenReturn(testValue) - mockAead - .when { - $0.decrypt( - authenticatedCipherText: anyArray(), - secretKey: anyArray(), - nonce: anyArray(), - additionalData: anyArray() - ) - } - .thenReturn(testValue) - - _ = mockAead.encrypt(message: [], secretKey: [], nonce: []) - _ = mockAead.decrypt(authenticatedCipherText: [], secretKey: [], nonce: []) - - expect(mockAead) - .to(call { - $0.encrypt(message: anyArray(), secretKey: anyArray(), nonce: anyArray(), additionalData: anyArray()) - }) - - expect(mockAead) - .to(call { - $0.decrypt( - authenticatedCipherText: anyArray(), - secretKey: anyArray(), - nonce: anyArray(), - additionalData: anyArray() - ) - }) - } - } - - describe("a GenericHashType") { - let testValue: [UInt8] = [1, 2, 3] - - it("provides the default values in it's extensions") { - let mockGenericHash: MockGenericHash = MockGenericHash() - mockGenericHash - .when { $0.hash(message: anyArray(), key: anyArray()) } - .thenReturn(testValue) - mockGenericHash - .when { - $0.hashSaltPersonal( - message: anyArray(), - outputLength: any(), - key: anyArray(), - salt: anyArray(), - personal: anyArray() - ) - } - .thenReturn(testValue) - - _ = mockGenericHash.hash(message: []) - _ = mockGenericHash.hashSaltPersonal(message: [], outputLength: 0, salt: [], personal: []) - - expect(mockGenericHash) - .to(call { $0.hash(message: anyArray(), key: anyArray()) }) - expect(mockGenericHash) - .to(call { - $0.hashSaltPersonal( - message: anyArray(), - outputLength: any(), - key: anyArray(), - salt: anyArray(), - personal: anyArray() - ) - }) - } - } - } -} diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift index 7f9a45a13..4429b0ecd 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift @@ -15,93 +15,110 @@ class MessageReceiverDecryptionSpec: QuickSpec { override func spec() { var mockStorage: Storage! - var mockSodium: MockSodium! - var mockBox: MockBox! - var mockGenericHash: MockGenericHash! - var mockSign: MockSign! - var mockAeadXChaCha: MockAeadXChaCha20Poly1305Ietf! - var mockNonce24Generator: MockNonce24Generator! - var dependencies: SMKDependencies! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! describe("a MessageReceiver") { beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, SNMessagingKit.self ] ) - mockSodium = MockSodium() - mockBox = MockBox() - mockGenericHash = MockGenericHash() - mockSign = MockSign() - mockAeadXChaCha = MockAeadXChaCha20Poly1305Ietf() - mockNonce24Generator = MockNonce24Generator() - - mockAeadXChaCha - .when { $0.encrypt(message: anyArray(), secretKey: anyArray(), nonce: anyArray()) } - .thenReturn(nil) - - dependencies = SMKDependencies( + mockCrypto = MockCrypto() + dependencies = Dependencies( storage: mockStorage, - sodium: mockSodium, - box: mockBox, - genericHash: mockGenericHash, - sign: mockSign, - aeadXChaCha20Poly1305Ietf: mockAeadXChaCha, - nonceGenerator24: mockNonce24Generator + crypto: mockCrypto ) mockStorage.write { db in try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } - mockBox + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .encryptAeadXChaCha20( + message: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + using: dependencies + ) + ) + } + .thenReturn(nil) + mockCrypto .when { - $0.open( - anonymousCipherText: anyArray(), - recipientPublicKey: anyArray(), - recipientSecretKey: anyArray() + try $0.perform( + .open( + anonymousCipherText: anyArray(), + recipientPublicKey: anyArray(), + recipientSecretKey: anyArray() + ) ) } .thenReturn([UInt8](repeating: 0, count: 100)) - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) + ) + } .thenReturn( KeyPair( publicKey: Data(hex: TestConstants.blindedPublicKey).bytes, secretKey: Data(hex: TestConstants.edSecretKey).bytes ) ) - mockSodium - .when { - $0.sharedBlindedEncryptionKey( - secretKey: anyArray(), - otherBlindedPublicKey: anyArray(), - fromBlindedPublicKey: anyArray(), - toBlindedPublicKey: anyArray(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) ) } .thenReturn([]) - mockSodium - .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform(.generateBlindingFactor(serverPublicKey: any(), using: dependencies)) + } .thenReturn([]) - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(Data(hex: TestConstants.blindedPublicKey).bytes) - mockSign - .when { $0.toX25519(ed25519PublicKey: anyArray()) } + mockCrypto + .when { try $0.perform(.toX25519(ed25519PublicKey: anyArray())) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) } .thenReturn(true) - mockAeadXChaCha - .when { $0.decrypt(authenticatedCipherText: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + mockCrypto + .when { + try $0.perform( + .decryptAeadXChaCha20( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray() + ) + ) + } .thenReturn("TestMessage".data(using: .utf8)!.bytes + [UInt8](repeating: 0, count: 32)) - mockNonce24Generator - .when { $0.nonce() } + mockCrypto.when { $0.size(.nonce24) }.thenReturn(24) + mockCrypto.when { $0.size(.publicKey) }.thenReturn(32) + mockCrypto.when { $0.size(.signature) }.thenReturn(64) + mockCrypto + .when { try $0.perform(.generateNonce24()) } .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) } @@ -117,7 +134,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: SMKDependencies() + using: Dependencies() ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) @@ -126,12 +143,14 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot open the message") { - mockBox + mockCrypto .when { - $0.open( - anonymousCipherText: anyArray(), - recipientPublicKey: anyArray(), - recipientSecretKey: anyArray() + try $0.perform( + .open( + anonymousCipherText: anyArray(), + recipientPublicKey: anyArray(), + recipientSecretKey: anyArray() + ) ) } .thenReturn(nil) @@ -143,19 +162,21 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: dependencies + using: dependencies ) } .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if the open message is too short") { - mockBox + mockCrypto .when { - $0.open( - anonymousCipherText: anyArray(), - recipientPublicKey: anyArray(), - recipientSecretKey: anyArray() + try $0.perform( + .open( + anonymousCipherText: anyArray(), + recipientPublicKey: anyArray(), + recipientSecretKey: anyArray() + ) ) } .thenReturn([1, 2, 3]) @@ -167,15 +188,15 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: dependencies + using: dependencies ) } .to(throwError(MessageReceiverError.decryptionFailed)) } it("throws an error if it cannot verify the message") { - mockSign - .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + mockCrypto + .when { $0.verify(.signature(message: anyArray(), publicKey: anyArray(), signature: anyArray())) } .thenReturn(false) expect { @@ -185,14 +206,14 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: dependencies + using: dependencies ) } .to(throwError(MessageReceiverError.invalidSignature)) } it("throws an error if it cannot get the senders x25519 public key") { - mockSign.when { $0.toX25519(ed25519PublicKey: anyArray()) }.thenReturn(nil) + mockCrypto.when { try $0.perform(.toX25519(ed25519PublicKey: anyArray())) }.thenReturn(nil) expect { try MessageReceiver.decryptWithSessionProtocol( @@ -201,7 +222,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), - dependencies: dependencies + using: dependencies ) } .to(throwError(MessageReceiverError.decryptionFailed)) @@ -223,7 +244,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), - using: SMKDependencies() + using: Dependencies() ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) @@ -271,8 +292,16 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot get the blinded keyPair") { - mockSodium - .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) + ) + } .thenReturn(nil) expect { @@ -296,14 +325,16 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot get the decryption key") { - mockSodium - .when { - $0.sharedBlindedEncryptionKey( - secretKey: anyArray(), - otherBlindedPublicKey: anyArray(), - fromBlindedPublicKey: anyArray(), - toBlindedPublicKey: anyArray(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) ) } .thenReturn(nil) @@ -350,8 +381,16 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot decrypt the data") { - mockAeadXChaCha - .when { $0.decrypt(authenticatedCipherText: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + mockCrypto + .when { + try $0.perform( + .decryptAeadXChaCha20( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray() + ) + ) + } .thenReturn(nil) expect { @@ -375,8 +414,16 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if the inner bytes are too short") { - mockAeadXChaCha - .when { $0.decrypt(authenticatedCipherText: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + mockCrypto + .when { + try $0.perform( + .decryptAeadXChaCha20( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray() + ) + ) + } .thenReturn([1, 2, 3]) expect { @@ -400,8 +447,10 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot generate the blinding factor") { - mockSodium - .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform(.generateBlindingFactor(serverPublicKey: any(), using: dependencies)) + } .thenReturn(nil) expect { @@ -425,8 +474,8 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot generate the combined key") { - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(nil) expect { @@ -450,8 +499,8 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if the combined key does not match kA") { - mockSodium - .when { $0.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray()) } + mockCrypto + .when { try $0.perform(.combineKeys(lhsKeyBytes: anyArray(), rhsKeyBytes: anyArray())) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) expect { @@ -475,8 +524,8 @@ class MessageReceiverDecryptionSpec: QuickSpec { } it("throws an error if it cannot get the senders x25519 public key") { - mockSign - .when { $0.toX25519(ed25519PublicKey: anyArray()) } + mockCrypto + .when { try $0.perform(.toX25519(ed25519PublicKey: anyArray())) } .thenReturn(nil) expect { diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift index f937b3744..27510a4f5 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift @@ -15,53 +15,55 @@ class MessageSenderEncryptionSpec: QuickSpec { override func spec() { var mockStorage: Storage! - var mockBox: MockBox! - var mockSign: MockSign! - var mockNonce24Generator: MockNonce24Generator! - var dependencies: SMKDependencies! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! describe("a MessageSender") { + // MARK: - Configuration + beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, SNMessagingKit.self ] ) - mockBox = MockBox() - mockSign = MockSign() - mockNonce24Generator = MockNonce24Generator() + mockCrypto = MockCrypto() - dependencies = SMKDependencies( + dependencies = Dependencies( storage: mockStorage, - box: mockBox, - sign: mockSign, - nonceGenerator24: mockNonce24Generator + crypto: mockCrypto ) mockStorage.write { db in try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } - mockNonce24Generator - .when { $0.nonce() } + mockCrypto + .when { try $0.perform(.generateNonce24()) } .thenReturn(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!.bytes) } + // MARK: - when encrypting with the session protocol context("when encrypting with the session protocol") { beforeEach { - mockBox.when { $0.seal(message: anyArray(), recipientPublicKey: anyArray()) }.thenReturn([1, 2, 3]) - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn([]) + mockCrypto + .when { try $0.perform(.seal(message: anyArray(), recipientPublicKey: anyArray())) } + .thenReturn([1, 2, 3]) + mockCrypto + .when { try $0.perform(.signature(message: anyArray(), secretKey: anyArray())) } + .thenReturn([]) } + // MARK: -- can encrypt correctly it("can encrypt correctly") { - let result = mockStorage.write { db in + let result: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, for: "05\(TestConstants.publicKey)", - using: SMKDependencies(storage: mockStorage) + using: Dependencies() // Don't mock ) } @@ -70,8 +72,9 @@ class MessageSenderEncryptionSpec: QuickSpec { expect(result?.count).to(equal(155)) } + // MARK: -- returns the correct value when mocked it("returns the correct value when mocked") { - let result = mockStorage.write { db in + let result: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, @@ -83,13 +86,14 @@ class MessageSenderEncryptionSpec: QuickSpec { expect(result?.bytes).to(equal([1, 2, 3])) } + // MARK: -- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { mockStorage.write { db in _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) } - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionProtocol( db, @@ -102,10 +106,13 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if the signature generation fails it("throws an error if the signature generation fails") { - mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) + mockCrypto + .when { try $0.perform(.signature(message: anyArray(), secretKey: anyArray())) } + .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionProtocol( db, @@ -118,10 +125,13 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if the encryption fails it("throws an error if the encryption fails") { - mockBox.when { $0.seal(message: anyArray(), recipientPublicKey: anyArray()) }.thenReturn(nil) + mockCrypto + .when { try $0.perform(.seal(message: anyArray(), recipientPublicKey: anyArray())) } + .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionProtocol( db, @@ -135,9 +145,67 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: - when encrypting with the blinded session protocol context("when encrypting with the blinded session protocol") { - it("successfully encrypts") { - let result = mockStorage.write { db in + beforeEach { + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate(.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), using: dependencies)) + } + .thenReturn( + KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes + ) + ) + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) + ) + } + .thenReturn([1, 2, 3]) + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .encryptAeadXChaCha20( + message: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + additionalData: anyArray(), + using: dependencies + ) + ) + } + .thenReturn([2, 3, 4]) + } + + // MARK: -- can encrypt correctly + it("can encrypt correctly") { + let result: Data? = mockStorage.read { db in + try? MessageSender.encryptWithSessionBlindingProtocol( + db, + plaintext: "TestMessage".data(using: .utf8)!, + for: "15\(TestConstants.blindedPublicKey)", + openGroupPublicKey: TestConstants.serverPublicKey, + using: Dependencies() // Don't mock + ) + } + + // Note: A Nonce is used for this so we can't compare the exact value when not mocked + expect(result).toNot(beNil()) + expect(result?.count).to(equal(84)) + } + + // MARK: -- returns the correct value when mocked + it("returns the correct value when mocked") { + let result: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionBlindingProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, @@ -148,15 +216,12 @@ class MessageSenderEncryptionSpec: QuickSpec { } expect(result?.toHexString()) - .to(equal( - "00db16b6687382811d69875a5376f66acad9c49fe5e26bcf770c7e6e9c230299" + - "f61b315299dd1fa700dd7f34305c0465af9e64dc791d7f4123f1eeafa5b4d48b" + - "3ade4f4b2a2764762e5a2c7900f254bd91633b43" - )) + .to(equal("00020304a5b4d48b3ade4f4b2a2764762e5a2c7900f254bd91633b43")) } + // MARK: -- includes a version at the start of the encrypted value it("includes a version at the start of the encrypted value") { - let result = mockStorage.write { db in + let result: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionBlindingProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, @@ -169,8 +234,9 @@ class MessageSenderEncryptionSpec: QuickSpec { expect(result?.toHexString().prefix(2)).to(equal("00")) } + // MARK: -- includes the nonce at the end of the encrypted value it("includes the nonce at the end of the encrypted value") { - let maybeResult = mockStorage.write { db in + let maybeResult: Data? = mockStorage.read { db in try? MessageSender.encryptWithSessionBlindingProtocol( db, plaintext: "TestMessage".data(using: .utf8)!, @@ -186,8 +252,9 @@ class MessageSenderEncryptionSpec: QuickSpec { .to(equal("pbTUizreT0sqJ2R2LloseQDyVL2RYztD")) } + // MARK: -- throws an error if the recipient isn't a blinded id it("throws an error if the recipient isn't a blinded id") { - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, @@ -201,13 +268,14 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { mockStorage.write { db in _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) } - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, @@ -221,22 +289,21 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if it fails to generate a blinded keyPair it("throws an error if it fails to generate a blinded keyPair") { - let mockSodium: MockSodium = MockSodium() - let mockGenericHash: MockGenericHash = MockGenericHash() - dependencies = dependencies.with(sodium: mockSodium, genericHash: mockGenericHash) - - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + crypto.generate( + .blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + using: dependencies + ) ) } .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, @@ -250,38 +317,23 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if it fails to generate an encryption key it("throws an error if it fails to generate an encryption key") { - let mockSodium: MockSodium = MockSodium() - let mockGenericHash: MockGenericHash = MockGenericHash() - dependencies = dependencies.with(sodium: mockSodium, genericHash: mockGenericHash) - - mockSodium - .when { - $0.blindedKeyPair( - serverPublicKey: any(), - edKeyPair: any(), - genericHash: mockGenericHash - ) - } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - mockSodium - .when { - $0.sharedBlindedEncryptionKey( - secretKey: anyArray(), - otherBlindedPublicKey: anyArray(), - fromBlindedPublicKey: anyArray(), - toBlindedPublicKey: anyArray(), - genericHash: mockGenericHash + mockCrypto + .when { [dependencies = dependencies!] crypto in + try crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + using: dependencies + ) ) } .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, @@ -295,15 +347,23 @@ class MessageSenderEncryptionSpec: QuickSpec { } } + // MARK: -- throws an error if it fails to encrypt it("throws an error if it fails to encrypt") { - let mockAeadXChaCha: MockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() - dependencies = dependencies.with(aeadXChaCha20Poly1305Ietf: mockAeadXChaCha) - - mockAeadXChaCha - .when { $0.encrypt(message: anyArray(), secretKey: anyArray(), nonce: anyArray()) } + mockCrypto + .when { + try $0.perform( + .encryptAeadXChaCha20( + message: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + additionalData: anyArray(), + using: dependencies + ) + ) + } .thenReturn(nil) - mockStorage.write { db in + mockStorage.read { db in expect { try MessageSender.encryptWithSessionBlindingProtocol( db, diff --git a/SessionMessagingKitTests/Utilities/CryptoSMKSpec.swift b/SessionMessagingKitTests/Utilities/CryptoSMKSpec.swift new file mode 100644 index 000000000..5db9f95b9 --- /dev/null +++ b/SessionMessagingKitTests/Utilities/CryptoSMKSpec.swift @@ -0,0 +1,402 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class CryptoSMKSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var crypto: Crypto! + var mockCrypto: MockCrypto! + var dependencies: Dependencies! + + beforeEach { + crypto = Crypto() + mockCrypto = MockCrypto() + dependencies = Dependencies(crypto: crypto) + } + + describe("Crypto for SessionMessagingKit") { + + // MARK: - when extending Sign + context("when extending Sign") { + // MARK: -- can convert an ed25519 public key into an x25519 public key + it("can convert an ed25519 public key into an x25519 public key") { + let result = try? crypto.perform(.toX25519(ed25519PublicKey: TestConstants.edPublicKey.bytes)) + + expect(result?.toHexString()) + .to(equal("95ffb559d4e804e9b414a5178454c426f616b4a61089b217b41165dbb7c9fe2d")) + } + + // MARK: -- can convert an ed25519 private key into an x25519 private key + it("can convert an ed25519 private key into an x25519 private key") { + let result = try? crypto.perform(.toX25519(ed25519SecretKey: TestConstants.edSecretKey.bytes)) + + expect(result?.toHexString()) + .to(equal("c83f9a1479b103c275d2db2d6c199fdc6f589b29b742f6405e01cc5a9a1d135d")) + } + } + + // MARK: - when extending Sodium + context("when extending Sodium") { + // MARK: -- and generating a blinding factor + context("and generating a blinding factor") { + // MARK: --- successfully generates a blinding factor + it("successfully generates a blinding factor") { + let result = try? crypto.perform( + .generateBlindingFactor( + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result?.toHexString()) + .to(equal("84e3eb75028a9b73fec031b7448e322a68ca6485fad81ab1bead56f759ebeb0f")) + } + + // MARK: --- fails if the serverPublicKey is not a hex string + it("fails if the serverPublicKey is not a hex string") { + let result = try? crypto.perform( + .generateBlindingFactor( + serverPublicKey: "Test", + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + + // MARK: --- fails if it cannot hash the serverPublicKey bytes + it("fails if it cannot hash the serverPublicKey bytes") { + dependencies = Dependencies(crypto: mockCrypto) + mockCrypto + .when { try $0.perform(.hash(message: anyArray(), outputLength: any())) } + .thenReturn(nil) + + let result = try? crypto.perform( + .generateBlindingFactor( + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + } + + // MARK: -- and generating a blinded key pair + context("and generating a blinded key pair") { + // MARK: --- successfully generates a blinded key pair + it("successfully generates a blinded key pair") { + let result = crypto.generate( + .blindedKeyPair( + serverPublicKey: TestConstants.serverPublicKey, + edKeyPair: KeyPair( + publicKey: Data(hex: TestConstants.edPublicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ), + using: dependencies + ) + ) + + // Note: The first 64 characters of the secretKey are consistent but the chars after that always differ + expect(result?.publicKey.toHexString()).to(equal(TestConstants.blindedPublicKey)) + expect(String(result?.secretKey.toHexString().prefix(64) ?? "")) + .to(equal("16663322d6b684e1c9dcc02b9e8642c3affd3bc431a9ea9e63dbbac88ce7a305")) + } + + // MARK: --- fails if the edKeyPair public key length wrong + it("fails if the edKeyPair public key length wrong") { + let result = crypto.generate( + .blindedKeyPair( + serverPublicKey: TestConstants.serverPublicKey, + edKeyPair: KeyPair( + publicKey: Data(hex: String(TestConstants.edPublicKey.prefix(4))).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ), + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + + // MARK: --- fails if the edKeyPair secret key length wrong + it("fails if the edKeyPair secret key length wrong") { + let result = crypto.generate( + .blindedKeyPair( + serverPublicKey: TestConstants.serverPublicKey, + edKeyPair: KeyPair( + publicKey: Data(hex: TestConstants.edPublicKey).bytes, + secretKey: Data(hex: String(TestConstants.edSecretKey.prefix(4))).bytes + ), + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + + // MARK: --- fails if it cannot generate a blinding factor + it("fails if it cannot generate a blinding factor") { + let result = crypto.generate( + .blindedKeyPair( + serverPublicKey: "Test", + edKeyPair: KeyPair( + publicKey: Data(hex: TestConstants.edPublicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ), + using: dependencies + ) + ) + + expect(result).to(beNil()) + } + } + + // MARK: -- and generating a sogsSignature + context("and generating a sogsSignature") { + // MARK: --- generates a correct signature + it("generates a correct signature") { + let result = try? crypto.perform( + .sogsSignature( + message: "TestMessage".bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes, + blindedSecretKey: Data(hex: "44d82cc15c0a5056825cae7520b6b52d000a23eb0c5ed94c4be2d9dc41d2d409").bytes, + blindedPublicKey: Data(hex: "0bb7815abb6ba5142865895f3e5286c0527ba4d31dbb75c53ce95e91ffe025a2").bytes + ) + ) + + expect(result?.toHexString()) + .to(equal( + "dcc086abdd2a740d9260b008fb37e12aa0ff47bd2bd9e177bbbec37fd46705a9" + + "072ce747bda66c788c3775cdd7ad60ad15a478e0886779aad5d795fd7bf8350d" + )) + } + } + + // MARK: -- and combining keys + context("and combining keys") { + // MARK: --- generates a correct combined key + it("generates a correct combined key") { + let result = try? crypto.perform( + .combineKeys( + lhsKeyBytes: Data(hex: TestConstants.edSecretKey).bytes, + rhsKeyBytes: Data(hex: TestConstants.edPublicKey).bytes + ) + ) + + expect(result?.toHexString()) + .to(equal("1159b5d0fcfba21228eb2121a0f59712fa8276fc6e5547ff519685a40b9819e6")) + } + } + + // MARK: -- and creating a shared blinded encryption key + context("and creating a shared blinded encryption key") { + // MARK: --- generates a correct combined key + it("generates a correct combined key") { + let result = try? crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: Data(hex: TestConstants.edSecretKey).bytes, + otherBlindedPublicKey: Data(hex: TestConstants.blindedPublicKey).bytes, + fromBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, + toBlindedPublicKey: Data(hex: TestConstants.blindedPublicKey).bytes, + using: dependencies + ) + ) + + expect(result?.toHexString()) + .to(equal("388ee09e4c356b91f1cce5cc0aa0cf59e8e8cade69af61685d09c2d2731bc99e")) + } + + // MARK: --- fails if the scalar multiplication fails + it("fails if the scalar multiplication fails") { + let result = try? crypto.perform( + .sharedBlindedEncryptionKey( + secretKey: Data(hex: TestConstants.edSecretKey).bytes, + otherBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, + fromBlindedPublicKey: Data(hex: TestConstants.edPublicKey).bytes, + toBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, + using: dependencies + ) + ) + + expect(result?.toHexString()).to(beNil()) + } + } + + // MARK: -- and checking if a session id matches a blinded id + context("and checking if a session id matches a blinded id") { + // MARK: --- returns true when they match + it("returns true when they match") { + let result = crypto.verify( + .sessionId( + "05\(TestConstants.publicKey)", + matchesBlindedId: "15\(TestConstants.blindedPublicKey)", + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result).to(beTrue()) + } + + // MARK: --- returns false if given an invalid session id + it("returns false if given an invalid session id") { + let result = crypto.verify( + .sessionId( + "AB\(TestConstants.publicKey)", + matchesBlindedId: "15\(TestConstants.blindedPublicKey)", + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result).to(beFalse()) + } + + // MARK: --- returns false if given an invalid blinded id + it("returns false if given an invalid blinded id") { + let result = crypto.verify( + .sessionId( + "05\(TestConstants.publicKey)", + matchesBlindedId: "AB\(TestConstants.blindedPublicKey)", + serverPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + ) + + expect(result).to(beFalse()) + } + + // MARK: --- returns false if it fails to generate the blinding factor + it("returns false if it fails to generate the blinding factor") { + let result = crypto.verify( + .sessionId( + "05\(TestConstants.publicKey)", + matchesBlindedId: "15\(TestConstants.blindedPublicKey)", + serverPublicKey: "Test", + using: dependencies + ) + ) + + expect(result).to(beFalse()) + } + } + } + + // MARK: - when extending GenericHash + describe("when extending GenericHash") { + // MARK: -- and generating a hash with salt and personal values + context("and generating a hash with salt and personal values") { + // MARK: --- generates a hash correctly + it("generates a hash correctly") { + let result = try? crypto.perform( + .hashSaltPersonal( + message: "TestMessage".bytes, + outputLength: 32, + key: "Key".bytes, + salt: "Salt".bytes, + personal: "Personal".bytes + ) + ) + + expect(result).toNot(beNil()) + expect(result?.count).to(equal(32)) + } + + // MARK: --- generates a hash correctly with no key + it("generates a hash correctly with no key") { + let result = try? crypto.perform( + .hashSaltPersonal( + message: "TestMessage".bytes, + outputLength: 32, + key: nil, + salt: "Salt".bytes, + personal: "Personal".bytes + ) + ) + + expect(result).toNot(beNil()) + expect(result?.count).to(equal(32)) + } + + // MARK: --- fails if given invalid options + it("fails if given invalid options") { + let result = try? crypto.perform( + .hashSaltPersonal( + message: "TestMessage".bytes, + outputLength: 65, // Max of 64 + key: "Key".bytes, + salt: "Salt".bytes, + personal: "Personal".bytes + ) + ) + + expect(result).to(beNil()) + } + } + } + + // MARK: - when extending AeadXChaCha20Poly1305Ietf + context("when extending AeadXChaCha20Poly1305Ietf") { + // MARK: -- when encrypting + context("when encrypting") { + // MARK: --- encrypts correctly + it("encrypts correctly") { + let result = try? crypto.perform( + .encryptAeadXChaCha20( + message: "TestMessage".bytes, + secretKey: Data(hex: TestConstants.publicKey).bytes, + nonce: "TestNonce".bytes, + additionalData: nil, + using: Dependencies() + ) + ) + + expect(result).toNot(beNil()) + expect(result?.count).to(equal(27)) + } + + // MARK: --- encrypts correctly with additional data + it("encrypts correctly with additional data") { + let result = try? crypto.perform( + .encryptAeadXChaCha20( + message: "TestMessage".bytes, + secretKey: Data(hex: TestConstants.publicKey).bytes, + nonce: "TestNonce".bytes, + additionalData: "TestData".bytes, + using: Dependencies() + ) + ) + + expect(result).toNot(beNil()) + expect(result?.count).to(equal(27)) + } + + // MARK: --- fails if given an invalid key + it("fails if given an invalid key") { + let result = try? crypto.perform( + .encryptAeadXChaCha20( + message: "TestMessage".bytes, + secretKey: "TestKey".bytes, + nonce: "TestNonce".bytes, + additionalData: "TestData".bytes, + using: Dependencies() + ) + ) + + expect(result).to(beNil()) + } + } + } + } + } +} diff --git a/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift b/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift deleted file mode 100644 index 99668e499..000000000 --- a/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift +++ /dev/null @@ -1,352 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import SessionUtilitiesKit - -import Quick -import Nimble - -@testable import SessionMessagingKit - -class SodiumUtilitiesSpec: QuickSpec { - // MARK: - Spec - - override func spec() { - // MARK: - Sign - - describe("an extended Sign") { - var sign: Sign! - - beforeEach { - sign = Sodium().sign - } - - it("can convert an ed25519 public key into an x25519 public key") { - let result = sign.toX25519(ed25519PublicKey: TestConstants.edPublicKey.bytes) - - expect(result?.toHexString()) - .to(equal("95ffb559d4e804e9b414a5178454c426f616b4a61089b217b41165dbb7c9fe2d")) - } - - it("can convert an ed25519 private key into an x25519 private key") { - let result = sign.toX25519(ed25519SecretKey: TestConstants.edSecretKey.bytes) - - expect(result?.toHexString()) - .to(equal("c83f9a1479b103c275d2db2d6c199fdc6f589b29b742f6405e01cc5a9a1d135d")) - } - } - - // MARK: - Sodium - - describe("an extended Sodium") { - var sodium: Sodium! - var genericHash: GenericHashType! - - beforeEach { - sodium = Sodium() - genericHash = sodium.genericHash - } - - context("when generating a blinding factor") { - it("successfully generates a blinding factor") { - let result = sodium.generateBlindingFactor( - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result?.toHexString()) - .to(equal("84e3eb75028a9b73fec031b7448e322a68ca6485fad81ab1bead56f759ebeb0f")) - } - - it("fails if the serverPublicKey is not a hex string") { - let result = sodium.generateBlindingFactor( - serverPublicKey: "Test", - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - - it("fails if it cannot hash the serverPublicKey bytes") { - genericHash = MockGenericHash() - (genericHash as? MockGenericHash)? - .when { $0.hash(message: anyArray(), outputLength: any()) } - .thenReturn(nil) - - let result = sodium.generateBlindingFactor( - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - } - - context("when generating a blinded key pair") { - it("successfully generates a blinded key pair") { - let result = sodium.blindedKeyPair( - serverPublicKey: TestConstants.serverPublicKey, - edKeyPair: KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ), - genericHash: genericHash - ) - - // Note: The first 64 characters of the secretKey are consistent but the chars after that always differ - expect(result?.publicKey.toHexString()).to(equal(TestConstants.blindedPublicKey)) - expect(String(result?.secretKey.toHexString().prefix(64) ?? "")) - .to(equal("16663322d6b684e1c9dcc02b9e8642c3affd3bc431a9ea9e63dbbac88ce7a305")) - } - - it("fails if the edKeyPair public key length wrong") { - let result = sodium.blindedKeyPair( - serverPublicKey: TestConstants.serverPublicKey, - edKeyPair: KeyPair( - publicKey: Data(hex: String(TestConstants.edPublicKey.prefix(4))).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ), - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - - it("fails if the edKeyPair secret key length wrong") { - let result = sodium.blindedKeyPair( - serverPublicKey: TestConstants.serverPublicKey, - edKeyPair: KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: String(TestConstants.edSecretKey.prefix(4))).bytes - ), - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - - it("fails if it cannot generate a blinding factor") { - let result = sodium.blindedKeyPair( - serverPublicKey: "Test", - edKeyPair: KeyPair( - publicKey: Data(hex: TestConstants.edPublicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ), - genericHash: genericHash - ) - - expect(result).to(beNil()) - } - } - - context("when generating a sogsSignature") { - it("generates a correct signature") { - let result = sodium.sogsSignature( - message: "TestMessage".bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes, - blindedSecretKey: Data(hex: "44d82cc15c0a5056825cae7520b6b52d000a23eb0c5ed94c4be2d9dc41d2d409").bytes, - blindedPublicKey: Data(hex: "0bb7815abb6ba5142865895f3e5286c0527ba4d31dbb75c53ce95e91ffe025a2").bytes - ) - - expect(result?.toHexString()) - .to(equal( - "dcc086abdd2a740d9260b008fb37e12aa0ff47bd2bd9e177bbbec37fd46705a9" + - "072ce747bda66c788c3775cdd7ad60ad15a478e0886779aad5d795fd7bf8350d" - )) - } - } - - context("when combining keys") { - it("generates a correct combined key") { - let result = sodium.combineKeys( - lhsKeyBytes: Data(hex: TestConstants.edSecretKey).bytes, - rhsKeyBytes: Data(hex: TestConstants.edPublicKey).bytes - ) - - expect(result?.toHexString()) - .to(equal("1159b5d0fcfba21228eb2121a0f59712fa8276fc6e5547ff519685a40b9819e6")) - } - - it("fails if the scalar multiplication fails") { - let result = sodium.combineKeys( - lhsKeyBytes: sodium.generatePrivateKeyScalar(secretKey: Data(hex: TestConstants.edSecretKey).bytes), - rhsKeyBytes: Data(hex: TestConstants.publicKey).bytes - ) - - expect(result).to(beNil()) - } - } - - context("when creating a shared blinded encryption key") { - it("generates a correct combined key") { - let result = sodium.sharedBlindedEncryptionKey( - secretKey: Data(hex: TestConstants.edSecretKey).bytes, - otherBlindedPublicKey: Data(hex: TestConstants.blindedPublicKey).bytes, - fromBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, - toBlindedPublicKey: Data(hex: TestConstants.blindedPublicKey).bytes, - genericHash: genericHash - ) - - expect(result?.toHexString()) - .to(equal("388ee09e4c356b91f1cce5cc0aa0cf59e8e8cade69af61685d09c2d2731bc99e")) - } - - it("fails if the scalar multiplication fails") { - let result = sodium.sharedBlindedEncryptionKey( - secretKey: Data(hex: TestConstants.edSecretKey).bytes, - otherBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, - fromBlindedPublicKey: Data(hex: TestConstants.edPublicKey).bytes, - toBlindedPublicKey: Data(hex: TestConstants.publicKey).bytes, - genericHash: genericHash - ) - - expect(result?.toHexString()).to(beNil()) - } - } - - context("when checking if a session id matches a blinded id") { - it("returns true when they match") { - let result = sodium.sessionId( - "05\(TestConstants.publicKey)", - matchesBlindedId: "15\(TestConstants.blindedPublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result).to(beTrue()) - } - - it("returns false if given an invalid session id") { - let result = sodium.sessionId( - "AB\(TestConstants.publicKey)", - matchesBlindedId: "15\(TestConstants.blindedPublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result).to(beFalse()) - } - - it("returns false if given an invalid blinded id") { - let result = sodium.sessionId( - "05\(TestConstants.publicKey)", - matchesBlindedId: "AB\(TestConstants.blindedPublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - genericHash: genericHash - ) - - expect(result).to(beFalse()) - } - - it("returns false if it fails to generate the blinding factor") { - let result = sodium.sessionId( - "05\(TestConstants.publicKey)", - matchesBlindedId: "15\(TestConstants.blindedPublicKey)", - serverPublicKey: "Test", - genericHash: genericHash - ) - - expect(result).to(beFalse()) - } - } - } - - // MARK: - GenericHash - - describe("an extended GenericHash") { - var genericHash: GenericHashType! - - beforeEach { - genericHash = Sodium().genericHash - } - - context("when generating a hash with salt and personal values") { - it("generates a hash correctly") { - let result = genericHash.hashSaltPersonal( - message: "TestMessage".bytes, - outputLength: 32, - key: "Key".bytes, - salt: "Salt".bytes, - personal: "Personal".bytes - ) - - expect(result).toNot(beNil()) - expect(result?.count).to(equal(32)) - } - - it("generates a hash correctly with no key") { - let result = genericHash.hashSaltPersonal( - message: "TestMessage".bytes, - outputLength: 32, - key: nil, - salt: "Salt".bytes, - personal: "Personal".bytes - ) - - expect(result).toNot(beNil()) - expect(result?.count).to(equal(32)) - } - - it("fails if given invalid options") { - let result = genericHash.hashSaltPersonal( - message: "TestMessage".bytes, - outputLength: 65, // Max of 64 - key: "Key".bytes, - salt: "Salt".bytes, - personal: "Personal".bytes - ) - - expect(result).to(beNil()) - } - } - } - - // MARK: - AeadXChaCha20Poly1305IetfType - - describe("an extended AeadXChaCha20Poly1305IetfType") { - var aeadXchacha20poly1305ietf: AeadXChaCha20Poly1305IetfType! - - beforeEach { - aeadXchacha20poly1305ietf = Sodium().aead.xchacha20poly1305ietf - } - - context("when encrypting") { - it("encrypts correctly") { - let result = aeadXchacha20poly1305ietf.encrypt( - message: "TestMessage".bytes, - secretKey: Data(hex: TestConstants.publicKey).bytes, - nonce: "TestNonce".bytes, - additionalData: nil - ) - - expect(result).toNot(beNil()) - expect(result?.count).to(equal(27)) - } - - it("encrypts correctly with additional data") { - let result = aeadXchacha20poly1305ietf.encrypt( - message: "TestMessage".bytes, - secretKey: Data(hex: TestConstants.publicKey).bytes, - nonce: "TestNonce".bytes, - additionalData: "TestData".bytes - ) - - expect(result).toNot(beNil()) - expect(result?.count).to(equal(27)) - } - - it("fails if given an invalid key") { - let result = aeadXchacha20poly1305ietf.encrypt( - message: "TestMessage".bytes, - secretKey: "TestKey".bytes, - nonce: "TestNonce".bytes, - additionalData: "TestData".bytes - ) - - expect(result).to(beNil()) - } - } - } - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift deleted file mode 100644 index 83e6af787..000000000 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionSnodeKit -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -extension SMKDependencies { - public func with( - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - sodium: SodiumType? = nil, - box: BoxType? = nil, - genericHash: GenericHashType? = nil, - sign: SignType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - ed25519: Ed25519Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) -> SMKDependencies { - return SMKDependencies( - onionApi: (onionApi ?? self._onionApi.wrappedValue), - generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue), - storage: (storage ?? self._storage.wrappedValue), - scheduler: (scheduler ?? self._scheduler.wrappedValue), - sodium: (sodium ?? self._sodium.wrappedValue), - box: (box ?? self._box.wrappedValue), - genericHash: (genericHash ?? self._genericHash.wrappedValue), - sign: (sign ?? self._sign.wrappedValue), - aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf.wrappedValue), - ed25519: (ed25519 ?? self._ed25519.wrappedValue), - nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16.wrappedValue), - nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24.wrappedValue), - standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults.wrappedValue), - date: (date ?? self._date.wrappedValue) - ) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift deleted file mode 100644 index cb3888b59..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium - -@testable import SessionMessagingKit - -class MockAeadXChaCha20Poly1305Ietf: Mock, AeadXChaCha20Poly1305IetfType { - var KeyBytes: Int = 32 - var ABytes: Int = 16 - - func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { - return accept(args: [message, secretKey, nonce, additionalData]) as? Bytes - } - - func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { - return accept(args: [authenticatedCipherText, secretKey, nonce, additionalData]) as? Bytes - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockBox.swift b/SessionMessagingKitTests/_TestUtilities/MockBox.swift deleted file mode 100644 index 3a991eec9..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockBox.swift +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium - -@testable import SessionMessagingKit - -class MockBox: Mock, BoxType { - func seal(message: Bytes, recipientPublicKey: Bytes) -> Bytes? { - return accept(args: [message, recipientPublicKey]) as? Bytes - } - - func open(anonymousCipherText: Bytes, recipientPublicKey: Bytes, recipientSecretKey: Bytes) -> Bytes? { - return accept(args: [anonymousCipherText, recipientPublicKey, recipientSecretKey]) as? Bytes - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift deleted file mode 100644 index 259a18bfd..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -class MockEd25519: Mock, Ed25519Type { - func sign(data: Bytes, keyPair: KeyPair) throws -> Bytes? { - return accept(args: [data, keyPair]) as? Bytes - } - - func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool { - return accept(args: [signature, publicKey, data]) as! Bool - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift b/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift deleted file mode 100644 index f3eccdbc1..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium - -@testable import SessionMessagingKit - -class MockGenericHash: Mock, GenericHashType { - func hash(message: Bytes, key: Bytes?) -> Bytes? { - return accept(args: [message, key]) as? Bytes - } - - func hash(message: Bytes, outputLength: Int) -> Bytes? { - return accept(args: [message, outputLength]) as? Bytes - } - - func hashSaltPersonal(message: Bytes, outputLength: Int, key: Bytes?, salt: Bytes, personal: Bytes) -> Bytes? { - return accept(args: [message, outputLength, key, salt, personal]) as? Bytes - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift b/SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift deleted file mode 100644 index 3fcaab255..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockNonce16Generator.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -@testable import SessionMessagingKit - -class MockNonce16Generator: Mock, NonceGenerator16ByteType { - var NonceBytes: Int = 16 - - func nonce() -> Array { return accept() as! [UInt8] } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift b/SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift deleted file mode 100644 index 8b733af64..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockNonce24Generator.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -@testable import SessionMessagingKit - -class MockNonce24Generator: Mock, NonceGenerator24ByteType { - var NonceBytes: Int = 24 - - func nonce() -> Array { return accept() as! [UInt8] } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift index ec2b8ac10..d326d0a15 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift @@ -6,7 +6,7 @@ import SessionUtilitiesKit @testable import SessionMessagingKit -class MockOGMCache: Mock, OGMMutableCacheType { +class MockOGMCache: Mock, OGMCacheType { var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get { return accept() as? AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> } set { accept(args: [newValue]) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockSign.swift b/SessionMessagingKitTests/_TestUtilities/MockSign.swift deleted file mode 100644 index 67a4ebe7f..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockSign.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium - -@testable import SessionMessagingKit - -class MockSign: Mock, SignType { - var Bytes: Int = 64 - var PublicKeyBytes: Int = 32 - - func signature(message: Bytes, secretKey: Bytes) -> Bytes? { - return accept(args: [message, secretKey]) as? Bytes - } - - func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool { - return accept(args: [message, publicKey, signature]) as! Bool - } - - func toX25519(ed25519PublicKey: Bytes) -> Bytes? { - return accept(args: [ed25519PublicKey]) as? Bytes - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/MockSodium.swift b/SessionMessagingKitTests/_TestUtilities/MockSodium.swift deleted file mode 100644 index a679462e0..000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockSodium.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Sodium -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -class MockSodium: Mock, SodiumType { - func getBox() -> BoxType { return accept() as! BoxType } - func getGenericHash() -> GenericHashType { return accept() as! GenericHashType } - func getSign() -> SignType { return accept() as! SignType } - func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return accept() as! AeadXChaCha20Poly1305IetfType } - - func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? { - return accept(args: [serverPublicKey, genericHash]) as? Bytes - } - - func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair, genericHash: GenericHashType) -> KeyPair? { - return accept(args: [serverPublicKey, edKeyPair, genericHash]) as? KeyPair - } - - func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { - return accept(args: [message, secretKey, ka, kA]) as? Bytes - } - - func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? { - return accept(args: [lhsKeyBytes, rhsKeyBytes]) as? Bytes - } - - func sharedBlindedEncryptionKey(secretKey a: Bytes, otherBlindedPublicKey: Bytes, fromBlindedPublicKey kA: Bytes, toBlindedPublicKey kB: Bytes, genericHash: GenericHashType) -> Bytes? { - return accept(args: [a, otherBlindedPublicKey, kA, kB, genericHash]) as? Bytes - } - - func sessionId(_ sessionId: String, matchesBlindedId blindedSessionId: String, serverPublicKey: String, genericHash: GenericHashType) -> Bool { - return accept(args: [sessionId, blindedSessionId, serverPublicKey, genericHash]) as! Bool - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift deleted file mode 100644 index a2be81109..000000000 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionSnodeKit -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -extension OpenGroupManager.OGMDependencies { - public func with( - cache: OGMMutableCacheType? = nil, - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - sodium: SodiumType? = nil, - box: BoxType? = nil, - genericHash: GenericHashType? = nil, - sign: SignType? = nil, - aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, - ed25519: Ed25519Type? = nil, - nonceGenerator16: NonceGenerator16ByteType? = nil, - nonceGenerator24: NonceGenerator24ByteType? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) -> OpenGroupManager.OGMDependencies { - return OpenGroupManager.OGMDependencies( - cache: (cache ?? self._mutableCache.wrappedValue), - onionApi: (onionApi ?? self._onionApi.wrappedValue), - generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue), - storage: (storage ?? self._storage.wrappedValue), - scheduler: (scheduler ?? self._scheduler.wrappedValue), - sodium: (sodium ?? self._sodium.wrappedValue), - box: (box ?? self._box.wrappedValue), - genericHash: (genericHash ?? self._genericHash.wrappedValue), - sign: (sign ?? self._sign.wrappedValue), - aeadXChaCha20Poly1305Ietf: (aeadXChaCha20Poly1305Ietf ?? self._aeadXChaCha20Poly1305Ietf.wrappedValue), - ed25519: (ed25519 ?? self._ed25519.wrappedValue), - nonceGenerator16: (nonceGenerator16 ?? self._nonceGenerator16.wrappedValue), - nonceGenerator24: (nonceGenerator24 ?? self._nonceGenerator24.wrappedValue), - standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults.wrappedValue), - date: (date ?? self._date.wrappedValue) - ) - } -} diff --git a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift deleted file mode 100644 index 89aab1217..000000000 --- a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import SessionSnodeKit -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -// FIXME: Change 'OnionRequestAPIType' to have instance methods instead of static methods once everything is updated to use 'Dependencies' -class TestOnionRequestAPI: OnionRequestAPIType { - struct RequestData: Codable { - let urlString: String? - let httpMethod: String - let headers: [String: String] - let body: Data? - let destination: OnionRequestAPIDestination - - var publicKey: String? { - switch destination { - case .snode: return nil - case .server(_, _, let x25519PublicKey, _, _): return x25519PublicKey - } - } - } - - class ResponseInfo: ResponseInfoType { - let requestData: RequestData - let code: Int - let headers: [String: String] - - init(requestData: RequestData, code: Int, headers: [String: String]) { - self.requestData = requestData - self.code = code - self.headers = headers - } - } - - class var mockResponse: Data? { return nil } - - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - let responseInfo: ResponseInfo = ResponseInfo( - requestData: RequestData( - urlString: request.url?.absoluteString, - httpMethod: (request.httpMethod ?? "GET"), - headers: (request.allHTTPHeaderFields ?? [:]), - body: request.httpBody, - destination: OnionRequestAPIDestination.server( - host: (request.url?.host ?? ""), - target: OnionRequestAPIVersion.v4.rawValue, - x25519PublicKey: x25519PublicKey, - scheme: request.url!.scheme, - port: request.url!.port.map { UInt16($0) } - ) - ), - code: 200, - headers: [:] - ) - - return Just((responseInfo, mockResponse)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - let responseInfo: ResponseInfo = ResponseInfo( - requestData: RequestData( - urlString: "\(snode.address):\(snode.port)/onion_req/v2", - httpMethod: "POST", - headers: [:], - body: payload, - destination: OnionRequestAPIDestination.snode(snode) - ), - code: 200, - headers: [:] - ) - - return Just((responseInfo, mockResponse)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } -} diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 4728da4d3..c7b4cd2b9 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -175,7 +175,13 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { + func attachmentApproval( + _ attachmentApproval: AttachmentApprovalViewController, + didApproveAttachments attachments: [SignalAttachment], + forThreadId threadId: String, + messageText: String?, + using dependencies: Dependencies = Dependencies() + ) { // Sharing a URL or plain text will populate the 'messageText' field so in those // cases we should ignore the attachments let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].isUrl) @@ -198,7 +204,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // Resume database NotificationCenter.default.post(name: Database.resumeNotification, object: self) - Storage.shared + dependencies.storage .writePublisher { db -> MessageSender.PreparedSendData in guard let threadVariant: SessionThread.Variant = try SessionThread @@ -262,12 +268,13 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView db, interaction: interaction, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + using: dependencies ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) } - .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0, using: dependencies) } + .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index c0b7eff3c..f867542eb 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -76,11 +76,16 @@ public extension SnodeReceivedMessageInfo { // MARK: - GRDB Interactions public extension SnodeReceivedMessageInfo { - static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) { + static func pruneExpiredMessageHashInfo( + for snode: Snode, + namespace: SnodeAPI.Namespace, + associatedWith publicKey: String, + using dependencies: Dependencies + ) { // Delete any expired SnodeReceivedMessageInfo values associated to a specific node (even // though this runs very quickly we fetch the rowIds we want to delete from a 'read' call // to avoid blocking the write queue since this method is called very frequently) - let rowIds: [Int64] = Storage.shared + let rowIds: [Int64] = dependencies.storage .read { db in // Only prune the hashes if new hashes exist for this Snode (if they don't then // we don't want to clear out the legacy hashes) @@ -102,7 +107,7 @@ public extension SnodeReceivedMessageInfo { // If there are no rowIds to delete then do nothing guard !rowIds.isEmpty else { return } - Storage.shared.write { db in + dependencies.storage.write { db in try SnodeReceivedMessageInfo .filter(rowIds.contains(Column.rowID)) .deleteAll(db) @@ -114,8 +119,13 @@ public extension SnodeReceivedMessageInfo { /// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's /// very common for this method to be called after the hash value has been updated but before the various `read` threads /// have been updated, resulting in a pointless fetch for data the app has already received - static func fetchLastNotExpired(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { - return Storage.shared.read { db in + static func fetchLastNotExpired( + for snode: Snode, + namespace: SnodeAPI.Namespace, + associatedWith publicKey: String, + using dependencies: Dependencies + ) -> SnodeReceivedMessageInfo? { + return dependencies.storage.read { db in let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo .filter( SnodeReceivedMessageInfo.Columns.wasDeletedOrInvalid == nil || diff --git a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift index c7b226189..22cdad93d 100644 --- a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift +++ b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift @@ -17,7 +17,7 @@ public enum GetSnodePoolJob: JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies ) { // If we already have cached Snodes then we still want to trigger the 'SnodeAPI.getSnodePool' // but we want to succeed this job immediately (since it's marked as blocking), this allows us @@ -35,7 +35,7 @@ public enum GetSnodePoolJob: JobExecutor { // If we don't have the snode pool cached then we should also try to build the path (this will // speed up the onboarding process for new users because it can run before the user is created) SnodeAPI.getSnodePool() - .flatMap { _ in OnionRequestAPI.getPath(excluding: nil) } + .flatMap { _ in OnionRequestAPI.getPath(excluding: nil, using: dependencies) } .subscribe(on: queue) .receive(on: queue) .sinkUntilComplete( @@ -53,13 +53,14 @@ public enum GetSnodePoolJob: JobExecutor { ) } - public static func run() { + public static func run(using dependencies: Dependencies = Dependencies()) { GetSnodePoolJob.run( Job(variant: .getSnodePool), queue: .global(qos: .background), success: { _, _, _ in }, failure: { _, _, _, _ in }, - deferred: { _, _ in } + deferred: { _, _ in }, + using: dependencies ) } } diff --git a/SessionSnodeKit/Networking/NetworkType+SnodeKit.swift b/SessionSnodeKit/Networking/NetworkType+SnodeKit.swift deleted file mode 100644 index 04969735d..000000000 --- a/SessionSnodeKit/Networking/NetworkType+SnodeKit.swift +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation diff --git a/SessionSnodeKit/Networking/OnionRequestAPI.swift b/SessionSnodeKit/Networking/OnionRequestAPI.swift index 926c49008..ef06b4a20 100644 --- a/SessionSnodeKit/Networking/OnionRequestAPI.swift +++ b/SessionSnodeKit/Networking/OnionRequestAPI.swift @@ -6,23 +6,31 @@ import CryptoKit import GRDB import SessionUtilitiesKit -public protocol OnionRequestAPIType { - static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> -} - -public extension OnionRequestAPIType { - static func sendOnionRequest(_ payload: Data, to snode: Snode) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return sendOnionRequest(payload, to: snode, timeout: HTTP.defaultTimeout) +public extension Network.RequestType { + static func onionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval = HTTP.defaultTimeout) -> Network.RequestType { + return Network.RequestType( + id: "onionRequest", + url: snode.address, + method: "POST", + body: payload, + args: [payload, snode, timeout] + ) { OnionRequestAPI.sendOnionRequest(payload, to: snode, timeout: timeout) } } - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> AnyPublisher<(ResponseInfoType, Data?), Error> { - return sendOnionRequest(request, to: server, with: x25519PublicKey, timeout: HTTP.defaultTimeout) + static func onionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval = HTTP.defaultTimeout) -> Network.RequestType { + return Network.RequestType( + id: "onionRequest", + url: request.url?.absoluteString, + method: request.httpMethod, + headers: request.allHTTPHeaderFields, + body: request.httpBody, + args: [request, server, x25519PublicKey, timeout] + ) { OnionRequestAPI.sendOnionRequest(request, to: server, with: x25519PublicKey, timeout: timeout) } } } /// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. -public enum OnionRequestAPI: OnionRequestAPIType { +public enum OnionRequestAPI { private static var buildPathsPublisher: Atomic?> = Atomic(nil) private static var pathFailureCount: Atomic<[[Snode]: UInt]> = Atomic([:]) private static var snodeFailureCount: Atomic<[Snode: UInt]> = Atomic([:]) @@ -66,12 +74,12 @@ public enum OnionRequestAPI: OnionRequestAPIType { // MARK: - Private API /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. - private static func testSnode(_ snode: Snode) -> AnyPublisher { + private static func testSnode(_ snode: Snode, using dependencies: Dependencies) -> AnyPublisher { let url = "\(snode.address):\(snode.port)/get_stats/v1" let timeout: TimeInterval = 3 // Use a shorter timeout for testing return HTTP.execute(.get, url, timeout: timeout) - .decoded(as: SnodeAPI.GetStatsResponse.self) + .decoded(as: SnodeAPI.GetStatsResponse.self, using: dependencies) .tryMap { response -> Void in guard let version: Version = response.version else { throw OnionRequestAPIError.missingSnodeVersion } guard version >= Version(major: 2, minor: 0, patch: 7) else { @@ -86,7 +94,10 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with /// `Error.insufficientSnodes` if not enough (reliable) snodes are available. - private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> AnyPublisher, Error> { + private static func getGuardSnodes( + reusing reusableGuardSnodes: [Snode], + using dependencies: Dependencies + ) -> AnyPublisher, Error> { guard guardSnodes.wrappedValue.count < targetGuardSnodeCount else { return Just(guardSnodes.wrappedValue) .setFailureType(to: Error.self) @@ -115,7 +126,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { SNLog("Testing guard snode: \(candidate).") // Loop until a reliable guard snode is found - return testSnode(candidate) + return testSnode(candidate, using: dependencies) .map { _ in candidate } .catch { _ in return Just(()) @@ -143,7 +154,10 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes` /// if not enough (reliable) snodes are available. @discardableResult - private static func buildPaths(reusing reusablePaths: [[Snode]]) -> AnyPublisher<[[Snode]], Error> { + private static func buildPaths( + reusing reusablePaths: [[Snode]], + using dependencies: Dependencies + ) -> AnyPublisher<[[Snode]], Error> { if let existingBuildPathsPublisher = buildPathsPublisher.wrappedValue { return existingBuildPathsPublisher } @@ -164,7 +178,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// Need to include the post-request code and a `shareReplay` within the publisher otherwise it can still be executed /// multiple times as a result of multiple subscribers let reusableGuardSnodes = reusablePaths.map { $0[0] } - let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes) + let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes, using: dependencies) .flatMap { (guardSnodes: Set) -> AnyPublisher<[[Snode]], Error> in var unusedSnodes: Set = SnodeAPI.snodePool.wrappedValue .subtracting(guardSnodes) @@ -227,7 +241,10 @@ public enum OnionRequestAPI: OnionRequestAPIType { } /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. - internal static func getPath(excluding snode: Snode?) -> AnyPublisher<[Snode], Error> { + internal static func getPath( + excluding snode: Snode?, + using dependencies: Dependencies + ) -> AnyPublisher<[Snode], Error> { guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } let paths: [[Snode]] = OnionRequestAPI.paths @@ -257,7 +274,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { else if !paths.isEmpty { if let snode = snode { if let path = paths.first(where: { !$0.contains(snode) }) { - buildPaths(reusing: paths) // Re-build paths in the background + buildPaths(reusing: paths, using: dependencies) // Re-build paths in the background .subscribe(on: DispatchQueue.global(qos: .background)) .sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in }) .store(in: &cancellable) @@ -267,7 +284,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { .eraseToAnyPublisher() } else { - return buildPaths(reusing: paths) + return buildPaths(reusing: paths, using: dependencies) .flatMap { paths in guard let path: [Snode] = paths.filter({ !$0.contains(snode) }).randomElement() else { return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes) @@ -282,7 +299,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } } else { - buildPaths(reusing: paths) // Re-build paths in the background + buildPaths(reusing: paths, using: dependencies) // Re-build paths in the background .subscribe(on: DispatchQueue.global(qos: .background)) .sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in }) .store(in: &cancellable) @@ -298,7 +315,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { } } else { - return buildPaths(reusing: []) + return buildPaths(reusing: [], using: dependencies) .flatMap { paths in if let snode = snode { if let path = paths.filter({ !$0.contains(snode) }).randomElement() { @@ -330,7 +347,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { private static func drop(_ snode: Snode) throws { // We repair the path here because we can do it sync. In the case where we drop a whole - // path we leave the re-building up to getPath(excluding:) because re-building the path + // path we leave the re-building up to getPath(excluding:using:) because re-building the path // in that case is async. OnionRequestAPI.snodeFailureCount.mutate { $0[snode] = 0 } var oldPaths = paths @@ -375,7 +392,8 @@ public enum OnionRequestAPI: OnionRequestAPIType { /// Builds an onion around `payload` and returns the result. private static func buildOnion( around payload: Data, - targetedAt destination: OnionRequestAPIDestination + targetedAt destination: OnionRequestAPIDestination, + using dependencies: Dependencies ) -> AnyPublisher { var guardSnode: Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination @@ -384,7 +402,7 @@ public enum OnionRequestAPI: OnionRequestAPIType { if case .snode(let snode) = destination { snodeToExclude = snode } - return getPath(excluding: snodeToExclude) + return getPath(excluding: snodeToExclude, using: dependencies) .flatMap { path -> AnyPublisher in guardSnode = path.first! @@ -490,11 +508,12 @@ public enum OnionRequestAPI: OnionRequestAPIType { with payload: Data, to destination: OnionRequestAPIDestination, version: OnionRequestAPIVersion, - timeout: TimeInterval = HTTP.defaultTimeout + timeout: TimeInterval = HTTP.defaultTimeout, + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { var guardSnode: Snode? - return buildOnion(around: payload, targetedAt: destination) + return buildOnion(around: payload, targetedAt: destination, using: dependencies) .flatMap { intermediate -> AnyPublisher<(ResponseInfoType, Data?), Error> in guardSnode = intermediate.guardSnode let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" diff --git a/SessionSnodeKit/Networking/SnodeAPI.swift b/SessionSnodeKit/Networking/SnodeAPI.swift index 04e1836c2..159d13fad 100644 --- a/SessionSnodeKit/Networking/SnodeAPI.swift +++ b/SessionSnodeKit/Networking/SnodeAPI.swift @@ -6,6 +6,18 @@ import Sodium import GRDB import SessionUtilitiesKit +public extension Network.RequestType { + static func message( + _ message: SnodeMessage, + in namespace: SnodeAPI.Namespace, + using dependencies: Dependencies = Dependencies() + ) -> Network.RequestType { + return Network.RequestType(id: "snodeAPI.sendMessage", args: [message, namespace]) { + SnodeAPI.sendMessage(message, in: namespace, using: dependencies) + } + } +} + public final class SnodeAPI { internal static let sodium: Atomic = Atomic(Sodium()) @@ -135,11 +147,13 @@ public final class SnodeAPI { return !hasInsufficientSnodes } - public static func getSnodePool() -> AnyPublisher, Error> { + public static func getSnodePool( + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher, Error> { loadSnodePoolIfNeeded() let now: Date = Date() - let hasSnodePoolExpired: Bool = Storage.shared[.lastSnodePoolRefreshDate] + let hasSnodePoolExpired: Bool = dependencies.storage[.lastSnodePoolRefreshDate] .map { now.timeIntervalSince($0) > 2 * 60 * 60 } .defaulting(to: true) let snodePool: Set = SnodeAPI.snodePool.wrappedValue @@ -163,10 +177,10 @@ public final class SnodeAPI { } let targetPublisher: AnyPublisher, Error> = { - guard snodePool.count >= minSnodePoolCount else { return getSnodePoolFromSeedNode() } + guard snodePool.count >= minSnodePoolCount else { return getSnodePoolFromSeedNode(using: dependencies) } - return getSnodePoolFromSnode() - .catch { _ in getSnodePoolFromSeedNode() } + return getSnodePoolFromSnode(using: dependencies) + .catch { _ in getSnodePoolFromSeedNode(using: dependencies) } .eraseToAnyPublisher() }() @@ -199,7 +213,10 @@ public final class SnodeAPI { } } - public static func getSessionID(for onsName: String) -> AnyPublisher { + public static func getSessionID( + for onsName: String, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { let validationCount = 3 // The name must be lowercased @@ -236,7 +253,8 @@ public final class SnodeAPI { ) ), to: snode, - associatedWith: nil + associatedWith: nil, + using: dependencies ) .decoded(as: ONSResolveResponse.self) .tryMap { _, response -> String in @@ -264,7 +282,7 @@ public final class SnodeAPI { public static func getSwarm( for publicKey: String, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher, Error> { loadSwarmIfNeeded(for: publicKey) @@ -304,14 +322,14 @@ public final class SnodeAPI { refreshingConfigHashes: [String] = [], from snode: Snode, associatedWith publicKey: String, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) .eraseToAnyPublisher() } - let userX25519PublicKey: String = getUserHexEncodedPublicKey() + let userX25519PublicKey: String = getUserHexEncodedPublicKey(using: dependencies) return Just(()) .setFailureType(to: Error.self) @@ -324,14 +342,16 @@ public final class SnodeAPI { SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo( for: snode, namespace: namespace, - associatedWith: publicKey + associatedWith: publicKey, + using: dependencies ) result[namespace] = SnodeReceivedMessageInfo .fetchLastNotExpired( for: snode, namespace: namespace, - associatedWith: publicKey + associatedWith: publicKey, + using: dependencies )? .hash } @@ -445,7 +465,7 @@ public final class SnodeAPI { .grouped(by: \.expiry) .mapValues({ groupedResults in groupedResults.map { $0.hash } }) { - Storage.shared.writeAsync { db in + dependencies.storage.writeAsync { db in try groupedExpiryResult.forEach { updatedExpiry, hashes in try SnodeReceivedMessageInfo .filter(hashes.contains(SnodeReceivedMessageInfo.Columns.hash)) @@ -493,7 +513,7 @@ public final class SnodeAPI { in namespace: SnodeAPI.Namespace, from snode: Snode, associatedWith publicKey: String, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?), Error> { return Deferred { Future { resolver in @@ -501,14 +521,16 @@ public final class SnodeAPI { SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo( for: snode, namespace: namespace, - associatedWith: publicKey + associatedWith: publicKey, + using: dependencies ) let maybeLastHash: String? = SnodeReceivedMessageInfo .fetchLastNotExpired( for: snode, namespace: namespace, - associatedWith: publicKey + associatedWith: publicKey, + using: dependencies )? .hash @@ -592,7 +614,7 @@ public final class SnodeAPI { public static func sendMessage( _ message: SnodeMessage, in namespace: Namespace, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies ) -> AnyPublisher<(ResponseInfoType, SendMessagesResponse), Error> { let publicKey: String = message.recipient let userX25519PublicKey: String = getUserHexEncodedPublicKey() @@ -661,7 +683,7 @@ public final class SnodeAPI { public static func sendConfigMessages( _ messages: [(message: SnodeMessage, namespace: Namespace)], allObsoleteHashes: [String], - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard !messages.isEmpty, @@ -755,7 +777,7 @@ public final class SnodeAPI { publicKey: String, serverHashes: [String], updatedExpiryMs: UInt64, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[String: [(hash: String, expiry: UInt64)]], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) @@ -796,7 +818,7 @@ public final class SnodeAPI { public static func revokeSubkey( publicKey: String, subkeyToRevoke: String, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) @@ -839,14 +861,14 @@ public final class SnodeAPI { public static func deleteMessages( publicKey: String, serverHashes: [String], - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[String: Bool], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) .eraseToAnyPublisher() } - let userX25519PublicKey: String = getUserHexEncodedPublicKey() + let userX25519PublicKey: String = getUserHexEncodedPublicKey(using: dependencies) return getSwarm(for: publicKey) .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: Bool], Error> in @@ -894,7 +916,7 @@ public final class SnodeAPI { /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. public static func deleteAllMessages( namespace: SnodeAPI.Namespace, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[String: Bool], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) @@ -941,7 +963,7 @@ public final class SnodeAPI { public static func deleteAllMessages( beforeMs: UInt64, namespace: SnodeAPI.Namespace, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher<[String: Bool], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) @@ -989,7 +1011,7 @@ public final class SnodeAPI { private static func getNetworkTime( from snode: Snode, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { return SnodeAPI .send( @@ -998,7 +1020,8 @@ public final class SnodeAPI { body: [:] ), to: snode, - associatedWith: nil + associatedWith: nil, + using: dependencies ) .decoded(as: GetNetworkTimestampResponse.self, using: dependencies) .map { _, response in response.timestamp } @@ -1013,7 +1036,7 @@ public final class SnodeAPI { } private static func getSnodePoolFromSeedNode( - dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies ) -> AnyPublisher, Error> { let request: SnodeRequest = SnodeRequest( endpoint: .jsonGetNServiceNodes, @@ -1073,7 +1096,7 @@ public final class SnodeAPI { } private static func getSnodePoolFromSnode( - dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies ) -> AnyPublisher, Error> { var snodePool = SnodeAPI.snodePool.wrappedValue var snodes: Set = [] @@ -1110,7 +1133,8 @@ public final class SnodeAPI { ) ), to: snode, - associatedWith: nil + associatedWith: nil, + using: dependencies ) .decoded(as: SnodePoolResponse.self, using: dependencies) .mapError { error -> Error in @@ -1149,7 +1173,7 @@ public final class SnodeAPI { request: SnodeRequest, to snode: Snode, associatedWith publicKey: String?, - using dependencies: SSKDependencies = SSKDependencies() + using dependencies: Dependencies ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { guard let payload: Data = try? JSONEncoder().encode(request) else { return Fail(error: HTTPError.invalidJSON) @@ -1175,11 +1199,8 @@ public final class SnodeAPI { .eraseToAnyPublisher() } - return dependencies.onionApi - .sendOnionRequest( - payload, - to: snode - ) + return dependencies.network + .send(.onionRequest(payload, to: snode)) .mapError { error in switch error { case HTTPError.httpRequestFailed(let statusCode, let data): diff --git a/SessionSnodeKit/SSKDependencies.swift b/SessionSnodeKit/SSKDependencies.swift deleted file mode 100644 index 875762f75..000000000 --- a/SessionSnodeKit/SSKDependencies.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit - -open class SSKDependencies: Dependencies { - public var _onionApi: Atomic - public var onionApi: OnionRequestAPIType.Type { - get { Dependencies.getValueSettingIfNull(&_onionApi) { OnionRequestAPI.self } } - set { _onionApi.mutate { $0 = newValue } } - } - - // MARK: - Initialization - - public init( - subscribeQueue: DispatchQueue? = nil, - receiveQueue: DispatchQueue? = nil, - onionApi: OnionRequestAPIType.Type? = nil, - generalCache: MutableGeneralCacheType? = nil, - storage: Storage? = nil, - scheduler: ValueObservationScheduler? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil - ) { - _onionApi = Atomic(onionApi) - - super.init( - subscribeQueue: subscribeQueue, - receiveQueue: receiveQueue, - generalCache: generalCache, - storage: storage, - scheduler: scheduler, - standardUserDefaults: standardUserDefaults, - date: date - ) - } -} diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index 0a9dfcf21..25f110c1e 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -24,7 +24,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { // MARK: - Configuration beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, @@ -44,10 +44,10 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { ).insert(db) } viewModel = ThreadDisappearingMessagesSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .contact, - config: DisappearingMessagesConfiguration.defaultWith("TestId") + config: DisappearingMessagesConfiguration.defaultWith("TestId"), + using: dependencies ) cancellables.append( viewModel.observableTableData @@ -127,10 +127,10 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { _ = try config.saved(db) } viewModel = ThreadDisappearingMessagesSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .contact, - config: config + config: config, + using: dependencies ) cancellables.append( viewModel.observableTableData @@ -232,11 +232,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { items?.first?.action?() - expect(didDismissScreen) - .toEventually( - beTrue(), - timeout: .milliseconds(100) - ) + expect(didDismissScreen).to(beTrue()) } it("saves the updated config") { diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 60ed929db..22fe69cda 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -16,6 +16,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { override func spec() { var mockStorage: Storage! + var mockCaches: MockCaches! var mockGeneralCache: MockGeneralCache! var disposables: [AnyCancellable] = [] var dependencies: Dependencies! @@ -35,12 +36,14 @@ class ThreadSettingsViewModelSpec: QuickSpec { SNUIKit.self ] ) + mockCaches = MockCaches() mockGeneralCache = MockGeneralCache() dependencies = Dependencies( - generalCache: mockGeneralCache, storage: mockStorage, + caches: mockCaches, scheduler: .immediate ) + mockCaches[.general] = mockGeneralCache mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(TestConstants.publicKey)") mockStorage.write { db in try SessionThread( @@ -68,12 +71,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { ).insert(db) } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .contact, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData @@ -166,12 +169,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "05\(TestConstants.publicKey)", threadVariant: .contact, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData @@ -440,12 +443,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .legacyGroup, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData @@ -482,12 +485,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { } viewModel = ThreadSettingsViewModel( - dependencies: dependencies, threadId: "TestId", threadVariant: .community, didTriggerSearch: { didTriggerSearchCallbackTriggered = true - } + }, + using: dependencies ) disposables.append( viewModel.observableTableData diff --git a/SessionTests/Settings/NotificationContentViewModelSpec.swift b/SessionTests/Settings/NotificationContentViewModelSpec.swift index e6d1e5999..c0ca9f1c4 100644 --- a/SessionTests/Settings/NotificationContentViewModelSpec.swift +++ b/SessionTests/Settings/NotificationContentViewModelSpec.swift @@ -23,7 +23,7 @@ class NotificationContentViewModelSpec: QuickSpec { // MARK: - Configuration beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self, diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index 796e9b29d..a0985631e 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -43,6 +43,28 @@ public extension Publisher { } .eraseToAnyPublisher() } + + func subscribe( + on scheduler: S, + options: S.SchedulerOptions? = nil, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher where S: Scheduler { + guard !dependencies.forceSynchronous else { return self.eraseToAnyPublisher() } + + return self.subscribe(on: scheduler, options: options) + .eraseToAnyPublisher() + } + + func receive( + on scheduler: S, + options: S.SchedulerOptions? = nil, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher where S: Scheduler { + guard !dependencies.forceSynchronous else { return self.eraseToAnyPublisher() } + + return self.receive(on: scheduler, options: options) + .eraseToAnyPublisher() + } } // MARK: - Convenience diff --git a/SessionUtilitiesKit/Combine/RetryWithDependencies.swift b/SessionUtilitiesKit/Combine/RetryWithDependencies.swift new file mode 100644 index 000000000..407296a8d --- /dev/null +++ b/SessionUtilitiesKit/Combine/RetryWithDependencies.swift @@ -0,0 +1,39 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine + +extension Publishers { + struct RetryWithDependencies: Publisher { + typealias Output = Upstream.Output + typealias Failure = Upstream.Failure + + let upstream: Upstream + let retries: Int + let dependencies: Dependencies + + func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { + upstream + .catch { [upstream, retries, dependencies] error -> AnyPublisher in + guard retries > 0 else { + return Fail(error: error).eraseToAnyPublisher() + } + + return RetryWithDependencies(upstream: upstream, retries: retries - 1, dependencies: dependencies) + .eraseToAnyPublisher() + } + .receive(subscriber: subscriber) + } + } +} + +public extension Publisher { + func retry(_ retries: Int, using dependencies: Dependencies) -> AnyPublisher { + guard !dependencies.forceSynchronous else { + return Publishers.RetryWithDependencies(upstream: self, retries: retries, dependencies: dependencies) + .eraseToAnyPublisher() + } + + return self.retry(retries).eraseToAnyPublisher() + } +} diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 800d581cf..ee1190ade 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -289,7 +289,9 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, guard let details: T = details, - let detailsData: Data = try? JSONEncoder().encode(details) + let detailsData: Data = try? JSONEncoder() + .with(outputFormatting: .sortedKeys) // Needed for deterministic comparison + .encode(details) else { return nil } self.priority = priority @@ -395,7 +397,11 @@ public extension Job { } func with(details: T) -> Job? { - guard let detailsData: Data = try? JSONEncoder().encode(details) else { return nil } + guard + let detailsData: Data = try? JSONEncoder() + .with(outputFormatting: .sortedKeys) // Needed for deterministic comparison + .encode(details) + else { return nil } return Job( id: self.id, diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 7f5c3dd13..5238ace7d 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -40,6 +40,7 @@ open class Storage { public static let defaultPublisherScheduler: ValueObservationScheduler = .async(onQueue: .main) fileprivate var dbWriter: DatabaseWriter? + internal var testDbWriter: DatabaseWriter? { dbWriter } private var migrator: DatabaseMigrator? private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>? @@ -435,10 +436,11 @@ open class Storage { // MARK: - Functions - @discardableResult public final func write( + @discardableResult public func write( fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T? ) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } @@ -453,12 +455,14 @@ open class Storage { fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T ) { writeAsync( fileName: fileName, functionName: functionName, lineNumber: lineNumber, + using: dependencies, updates: updates, completion: { _, _ in } ) @@ -468,6 +472,7 @@ open class Storage { fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void ) { @@ -492,6 +497,7 @@ open class Storage { fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T ) -> AnyPublisher { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { @@ -520,6 +526,7 @@ open class Storage { } open func readPublisher( + using dependencies: Dependencies = Dependencies(), value: @escaping (Database) throws -> T ) -> AnyPublisher { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { @@ -545,7 +552,10 @@ open class Storage { }.eraseToAnyPublisher() } - @discardableResult public final func read(_ value: (Database) throws -> T?) -> T? { + @discardableResult public func read( + using dependencies: Dependencies = Dependencies(), + _ value: (Database) throws -> T? + ) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } do { return try dbWriter.read(value) } diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift index 53c30f9c6..5cdbdd363 100644 --- a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -87,9 +87,7 @@ public extension Database { // Only allow a single observer per `dedupeId` per transaction, this allows us to // schedule an action to run at most once per transaction (eg. auto-scheduling a ConfigSyncJob // when receiving messages) - guard !TransactionHandler.registeredHandlers.wrappedValue.contains(dedupeId) else { - return - } + guard !TransactionHandler.registeredHandlers.wrappedValue.contains(dedupeId) else { return } add( transactionObserver: TransactionHandler( diff --git a/SessionUtilitiesKit/General/Caches.swift b/SessionUtilitiesKit/General/Caches.swift new file mode 100644 index 000000000..e1086b9bc --- /dev/null +++ b/SessionUtilitiesKit/General/Caches.swift @@ -0,0 +1,126 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +// MARK: - CacheType + +public protocol MutableCacheType {} +public protocol ImmutableCacheType {} + +// MARK: - Cache + +public class Cache {} + +// MARK: - CacheInfo + +public enum CacheInfo { + public class Config: Cache { + public let key: Int + public let createInstance: () -> M + public let mutableInstance: (M) -> MutableCacheType + public let immutableInstance: (M) -> I + + fileprivate init( + createInstance: @escaping () -> M, + mutableInstance: @escaping (M) -> MutableCacheType, + immutableInstance: @escaping (M) -> I + ) { + self.key = ObjectIdentifier(M.self).hashValue + self.createInstance = createInstance + self.mutableInstance = mutableInstance + self.immutableInstance = immutableInstance + } + } +} + +public extension CacheInfo { + static func create( + createInstance: @escaping () -> M, + mutableInstance: @escaping (M) -> MutableCacheType, + immutableInstance: @escaping (M) -> I + ) -> CacheInfo.Config { + return CacheInfo.Config( + createInstance: createInstance, + mutableInstance: mutableInstance, + immutableInstance: immutableInstance + ) + } +} + + +public protocol CacheType: MutableCacheType { + associatedtype ImmutableCache = ImmutableCacheType + associatedtype MutableCache: MutableCacheType + + init() + func mutableInstance() -> MutableCache + func immutableInstance() -> ImmutableCache +} + +public extension CacheType where MutableCache == Self { + func mutableInstance() -> Self { return self } +} + +public protocol CachesType { + subscript(cache: CacheInfo.Config) -> I { get } + + @discardableResult func mutate( + cache: CacheInfo.Config, + _ mutation: (inout M) -> R + ) -> R + @discardableResult func mutate( + cache: CacheInfo.Config, + _ mutation: (inout M) throws -> R + ) throws -> R +} + +// MARK: - Caches Logic + +public extension Dependencies { + class Caches: CachesType { + /// The caches need to be accessed as singleton instances so we store them in a static variable in the `Caches` type + private static var cacheInstances: Atomic<[Int: MutableCacheType]> = Atomic([:]) + + // MARK: - Initialization + + public init() {} + + // MARK: - Immutable Access + + public subscript(cache: CacheInfo.Config) -> I { + get { Caches.getValueSettingIfNull(cache: cache, &Caches.cacheInstances) } + } + + // MARK: - Mutable Access + + @discardableResult public func mutate(cache: CacheInfo.Config, _ mutation: (inout M) -> R) -> R { + return Caches.cacheInstances.mutate { caches in + var value: M = ((caches[cache.key] as? M) ?? cache.createInstance()) + return mutation(&value) + } + } + + @discardableResult public func mutate(cache: CacheInfo.Config, _ mutation: (inout M) throws -> R) throws -> R { + return try Caches.cacheInstances.mutate { caches in + var value: M = ((caches[cache.key] as? M) ?? cache.createInstance()) + return try mutation(&value) + } + } + + // MARK: - Convenience + + @discardableResult private static func getValueSettingIfNull( + cache: CacheInfo.Config, + _ store: inout Atomic<[Int: MutableCacheType]> + ) -> I { + guard let value: M = (store.wrappedValue[cache.key] as? M) else { + let value: M = cache.createInstance() + let mutableInstance: MutableCacheType = cache.mutableInstance(value) + store.mutate { $0[cache.key] = mutableInstance } + return cache.immutableInstance(value) + } + + return cache.immutableInstance(value) + } + } +} diff --git a/SessionUtilitiesKit/General/Data+Utilities.swift b/SessionUtilitiesKit/General/Data+Utilities.swift index bac5db7d9..1114ad973 100644 --- a/SessionUtilitiesKit/General/Data+Utilities.swift +++ b/SessionUtilitiesKit/General/Data+Utilities.swift @@ -14,9 +14,7 @@ public extension Data { return try decoder.decode(type, from: self) } - catch { - throw HTTPError.parsingFailed - } + catch { throw HTTPError.parsingFailed } } func removingIdPrefixIfNeeded() -> Data { diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift index 0621482ef..a6b99474e 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -3,107 +3,98 @@ import Foundation import GRDB -open class Dependencies { - /// These should not be accessed directly but rather via an instance of this type - private static let _generalCacheInstance: MutableGeneralCacheType = General.Cache() - private static let _generalCacheInstanceAccessQueue = DispatchQueue(label: "GeneralCacheInstanceAccess") - - public var _subscribeQueue: Atomic - public var subscribeQueue: DispatchQueue { - get { Dependencies.getValueSettingIfNull(&_subscribeQueue) { DispatchQueue.global(qos: .default) } } - set { _subscribeQueue.mutate { $0 = newValue } } - } - - public var _receiveQueue: Atomic - public var receiveQueue: DispatchQueue { - get { Dependencies.getValueSettingIfNull(&_receiveQueue) { DispatchQueue.global(qos: .default) } } - set { _receiveQueue.mutate { $0 = newValue } } - } - - public var _mutableGeneralCache: Atomic - public var mutableGeneralCache: Atomic { - get { - Dependencies.getMutableValueSettingIfNull(&_mutableGeneralCache) { - Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance } - } - } - } - public var generalCache: GeneralCacheType { - get { - Dependencies.getValueSettingIfNull(&_mutableGeneralCache) { - Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance } - } - } - set { - guard let mutableValue: MutableGeneralCacheType = newValue as? MutableGeneralCacheType else { return } - - _mutableGeneralCache.mutate { $0 = mutableValue } - } - } - - public var _storage: Atomic +public class Dependencies { + private var _storage: Atomic public var storage: Storage { get { Dependencies.getValueSettingIfNull(&_storage) { Storage.shared } } set { _storage.mutate { $0 = newValue } } } - public var _jobRunner: Atomic - public var jobRunner: JobRunnerType { - get { Dependencies.getValueSettingIfNull(&_jobRunner) { JobRunner.instance } } - set { _jobRunner.mutate { $0 = newValue } } + private var _network: Atomic + public var network: NetworkType { + get { Dependencies.getValueSettingIfNull(&_network) { Network() } } + set { _network.mutate { $0 = newValue } } } - public var _scheduler: Atomic - public var scheduler: ValueObservationScheduler { - get { Dependencies.getValueSettingIfNull(&_scheduler) { Storage.defaultPublisherScheduler } } - set { _scheduler.mutate { $0 = newValue } } + private var _crypto: Atomic + public var crypto: CryptoType { + get { Dependencies.getValueSettingIfNull(&_crypto) { Crypto() } } + set { _crypto.mutate { $0 = newValue } } } - public var _standardUserDefaults: Atomic + private var _standardUserDefaults: Atomic public var standardUserDefaults: UserDefaultsType { get { Dependencies.getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } } set { _standardUserDefaults.mutate { $0 = newValue } } } - public var _date: Atomic - public var date: Date { - get { Dependencies.getValueSettingIfNull(&_date) { Date() } } - set { _date.mutate { $0 = newValue } } + private var _caches: CachesType + public var caches: CachesType { + get { _caches } + set { _caches = newValue } } - public var _fixedTime: Atomic + private var _jobRunner: Atomic + public var jobRunner: JobRunnerType { + get { Dependencies.getValueSettingIfNull(&_jobRunner) { JobRunner.instance } } + set { _jobRunner.mutate { $0 = newValue } } + } + + private var _scheduler: Atomic + public var scheduler: ValueObservationScheduler { + get { Dependencies.getValueSettingIfNull(&_scheduler) { Storage.defaultPublisherScheduler } } + set { _scheduler.mutate { $0 = newValue } } + } + + private var _dateNow: Atomic + public var dateNow: Date { + get { (_dateNow.wrappedValue ?? Date()) } + set { _dateNow.mutate { $0 = newValue } } + } + + private var _fixedTime: Atomic public var fixedTime: Int { get { Dependencies.getValueSettingIfNull(&_fixedTime) { 0 } } set { _fixedTime.mutate { $0 = newValue } } } + private var _forceSynchronous: Bool + public var forceSynchronous: Bool { + get { _forceSynchronous } + set { _forceSynchronous = newValue } + } + + public var asyncExecutions: [Int: [() -> Void]] = [:] + // MARK: - Initialization public init( - subscribeQueue: DispatchQueue? = nil, - receiveQueue: DispatchQueue? = nil, - generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, + network: NetworkType? = nil, + crypto: CryptoType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + caches: CachesType = Caches(), jobRunner: JobRunnerType? = nil, scheduler: ValueObservationScheduler? = nil, - standardUserDefaults: UserDefaultsType? = nil, - date: Date? = nil, - fixedTime: Int? = nil + dateNow: Date? = nil, + fixedTime: Int? = nil, + forceSynchronous: Bool = false ) { - _subscribeQueue = Atomic(subscribeQueue) - _receiveQueue = Atomic(receiveQueue) - _mutableGeneralCache = Atomic(generalCache) _storage = Atomic(storage) + _network = Atomic(network) + _crypto = Atomic(crypto) + _standardUserDefaults = Atomic(standardUserDefaults) + _caches = caches _jobRunner = Atomic(jobRunner) _scheduler = Atomic(scheduler) - _standardUserDefaults = Atomic(standardUserDefaults) - _date = Atomic(date) + _dateNow = Atomic(dateNow) _fixedTime = Atomic(fixedTime) + _forceSynchronous = forceSynchronous } // MARK: - Convenience - public static func getValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> T { + private static func getValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> T { guard let value: T = maybeValue.wrappedValue else { let value: T = valueGenerator() maybeValue.mutate { $0 = value } @@ -113,7 +104,7 @@ open class Dependencies { return value } - public static func getMutableValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> Atomic { + private static func getMutableValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> Atomic { guard let value: T = maybeValue.wrappedValue else { let value: T = valueGenerator() maybeValue.mutate { $0 = value } @@ -122,4 +113,23 @@ open class Dependencies { return Atomic(value) } + +#if DEBUG + public func stepForwardInTime() { + let targetTime: Int = ((_fixedTime.wrappedValue ?? 0) + 1) + _fixedTime.mutate { $0 = targetTime } + + if let currentDate: Date = _dateNow.wrappedValue { + _dateNow.mutate { $0 = Date(timeIntervalSince1970: currentDate.timeIntervalSince1970 + 1) } + } + + // Run and clear any executions which should run at the target time + let targetKeys: [Int] = asyncExecutions.keys + .filter { $0 <= targetTime } + targetKeys.forEach { key in + asyncExecutions[key]?.forEach { $0() } + asyncExecutions[key] = nil + } + } +#endif } diff --git a/SessionUtilitiesKit/General/Dictionary+Utilities.swift b/SessionUtilitiesKit/General/Dictionary+Utilities.swift index 7adfe64aa..ee1fafd85 100644 --- a/SessionUtilitiesKit/General/Dictionary+Utilities.swift +++ b/SessionUtilitiesKit/General/Dictionary+Utilities.swift @@ -67,4 +67,18 @@ public extension Dictionary { return updatedDictionary } + + mutating func append(_ value: T?, toArrayOn key: Key?) where Value == [T] { + guard let key: Key = key, let value: T = value else { return } + + self[key] = (self[key] ?? []).appending(value) + } +} + +extension Dictionary where Value == Array<() -> Void> { + mutating func appendTo(_ key: Key?, _ value: @escaping () -> Void) { + guard let key: Key = key else { return } + + self[key] = (self[key] ?? []).appending(value) + } } diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 5f59f2b13..34ea83b3a 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -6,12 +6,20 @@ import GRDB // MARK: - General.Cache public enum General { - public class Cache: MutableGeneralCacheType { + public class Cache: GeneralCacheType { public var encodedPublicKey: String? = nil public var recentReactionTimestamps: [Int64] = [] } } +public extension Cache { + static let general: CacheInfo.Config = CacheInfo.create( + createInstance: { General.Cache() }, + mutableInstance: { $0 }, + immutableInstance: { $0 } + ) +} + // MARK: - GeneralError public enum GeneralError: Error { @@ -22,13 +30,13 @@ public enum GeneralError: Error { // MARK: - Convenience -public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> String { - if let cachedKey: String = dependencies.generalCache.encodedPublicKey { return cachedKey } +public func getUserHexEncodedPublicKey(_ db: Database? = nil, using dependencies: Dependencies = Dependencies()) -> String { + if let cachedKey: String = dependencies.caches[.general].encodedPublicKey { return cachedKey } if let publicKey: Data = Identity.fetchUserPublicKey(db) { // Can be nil under some circumstances let sessionId: SessionId = SessionId(.standard, publicKey: publicKey.bytes) - dependencies.mutableGeneralCache.mutate { $0.encodedPublicKey = sessionId.hexString } + dependencies.caches.mutate(cache: .general) { $0.encodedPublicKey = sessionId.hexString } return sessionId.hexString } @@ -37,14 +45,14 @@ public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Depe // MARK: - GeneralCacheType -public protocol MutableGeneralCacheType: GeneralCacheType { - var encodedPublicKey: String? { get set } - var recentReactionTimestamps: [Int64] { get set } -} - -/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a +/// This is a read-only version of the `General.Cache` designed to avoid unintentionally mutating the instance in a /// non-thread-safe way -public protocol GeneralCacheType { +public protocol ImmutableGeneralCacheType: ImmutableCacheType { var encodedPublicKey: String? { get } var recentReactionTimestamps: [Int64] { get } } + +public protocol GeneralCacheType: ImmutableGeneralCacheType, MutableCacheType { + var encodedPublicKey: String? { get set } + var recentReactionTimestamps: [Int64] { get set } +} diff --git a/SessionUtilitiesKit/General/Timer+MainThread.swift b/SessionUtilitiesKit/General/Timer+MainThread.swift index 7cea385a2..7805762ef 100644 --- a/SessionUtilitiesKit/General/Timer+MainThread.swift +++ b/SessionUtilitiesKit/General/Timer+MainThread.swift @@ -3,14 +3,24 @@ import Foundation extension Timer { - - @discardableResult - public static func scheduledTimerOnMainThread( + @discardableResult public static func scheduledTimerOnMainThread( withTimeInterval timeInterval: TimeInterval, repeats: Bool = false, + using dependencies: Dependencies = Dependencies(), block: @escaping (Timer) -> Void ) -> Timer { let timer = Timer(timeInterval: timeInterval, repeats: repeats, block: block) + + // If we are forcing synchrnonous execution (ie. running unit tests) then ceil the + // timeInterval for execution and append it to the execution set so the test can + // trigger the logic in a synchronous way) + guard !dependencies.forceSynchronous else { + dependencies.asyncExecutions.appendTo(Int(ceil(dependencies.dateNow.timeIntervalSince1970 + timeInterval))) { + block(timer) + } + return timer + } + RunLoop.main.add(timer, forMode: .common) return timer } diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 1f7d7e042..b118c0571 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -8,21 +8,22 @@ public protocol JobRunnerType { func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) func canStart(queue: JobQueue?) -> Bool + func afterBlockingQueue(callback: @escaping () -> ()) // MARK: - State Management func jobInfoFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: JobRunner.JobInfo] - func appDidFinishLaunching(dependencies: Dependencies) - func appDidBecomeActive(dependencies: Dependencies) - func startNonBlockingQueues(dependencies: Dependencies) + func appDidFinishLaunching(using dependencies: Dependencies) + func appDidBecomeActive(using dependencies: Dependencies) + func startNonBlockingQueues(using dependencies: Dependencies) func stopAndClearPendingJobs(exceptForVariant: Job.Variant?, onComplete: (() -> ())?) // MARK: - Job Scheduling - @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) -> Job? - func upsert(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) - @discardableResult func insert(_ db: Database, job: Job?, before otherJob: Job, dependencies: Dependencies) -> (Int64, Job)? + @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) -> Job? + func upsert(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) + @discardableResult func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, Job)? } // MARK: - JobRunnerType Convenience @@ -61,7 +62,11 @@ public extension JobRunnerType { inState state: JobRunner.JobState = .any, with jobDetails: T ) -> Bool { - guard let detailsData: Data = try? JSONEncoder().encode(jobDetails) else { return false } + guard + let detailsData: Data = try? JSONEncoder() + .with(outputFormatting: .sortedKeys) // Needed for deterministic comparison + .encode(jobDetails) + else { return false } return jobInfoFor(jobs: nil, state: state, variant: variant) .values @@ -103,7 +108,7 @@ public protocol JobExecutor { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies + using dependencies: Dependencies ) } @@ -130,11 +135,26 @@ public final class JobRunner: JobRunnerType { case notFound } - public struct JobInfo: Equatable { + public struct JobInfo: Equatable, CustomDebugStringConvertible { public let variant: Job.Variant public let threadId: String? public let interactionId: Int64? public let detailsData: Data? + + public var debugDescription: String { + let dataDescription: String = detailsData + .map { data in "Data(hex: \(data.toHexString()), \(data.bytes.count) bytes" } + .defaulting(to: "nil") + + return [ + "JobRunner.JobInfo(", + "variant: \(variant),", + " threadId: \(threadId ?? "nil"),", + " interactionId: \(interactionId.map { "\($0)" } ?? "nil"),", + " detailsData: \(dataDescription)", + ")" + ].joined() + } } // MARK: - Variables @@ -145,22 +165,23 @@ public final class JobRunner: JobRunnerType { private var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) internal var appReadyToStartQueues: Atomic = Atomic(false) + internal var appHasBecomeActive: Atomic = Atomic(false) internal var perSessionJobsCompleted: Atomic> = Atomic([]) internal var hasCompletedInitialBecomeActive: Atomic = Atomic(false) internal var shutdownBackgroundTask: Atomic = Atomic(nil) - // TODO: Check these??? - internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) - fileprivate static var canStartQueues: Atomic = Atomic(false) - private static var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) - - + private var canStartNonBlockingQueue: Bool { + blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue == true && + blockingQueue.wrappedValue?.isRunning.wrappedValue != true && + appHasBecomeActive.wrappedValue + } + // MARK: - Initialization init( isTestingJobRunner: Bool = false, variantsToExclude: [Job.Variant] = [], - dependencies: Dependencies = Dependencies() + using dependencies: Dependencies = Dependencies() ) { var jobVariants: Set = Job.Variant.allCases .filter { !variantsToExclude.contains($0) } @@ -177,6 +198,7 @@ public final class JobRunner: JobRunnerType { JobQueue( type: .blocking, qos: .default, + isTestingJobRunner: isTestingJobRunner, jobVariants: [] ) ) @@ -187,6 +209,7 @@ public final class JobRunner: JobRunnerType { type: .messageSend, executionType: .concurrent, // Allow as many jobs to run at once as supported by the device qos: .default, + isTestingJobRunner: isTestingJobRunner, jobVariants: [ jobVariants.remove(.attachmentUpload), jobVariants.remove(.messageSend), @@ -208,6 +231,7 @@ public final class JobRunner: JobRunnerType { // update message has been processed (ie. guaranteed to fail) executionType: .serial, qos: .default, + isTestingJobRunner: isTestingJobRunner, jobVariants: [ jobVariants.remove(.messageReceive), jobVariants.remove(.configMessageReceive) @@ -219,6 +243,7 @@ public final class JobRunner: JobRunnerType { JobQueue( type: .attachmentDownload, qos: .utility, + isTestingJobRunner: isTestingJobRunner, jobVariants: [ jobVariants.remove(.attachmentDownload) ].compactMap { $0 } @@ -229,6 +254,7 @@ public final class JobRunner: JobRunnerType { JobQueue( type: .general(number: 0), qos: .utility, + isTestingJobRunner: isTestingJobRunner, jobVariants: Array(jobVariants) ) ].reduce(into: [:]) { prev, next in @@ -243,7 +269,7 @@ public final class JobRunner: JobRunnerType { $0?.onQueueDrained = { [weak self] in // Once all blocking jobs have been completed we want to start running // the remaining job queues - self?.startNonBlockingQueues(dependencies: dependencies) + self?.startNonBlockingQueues(using: dependencies) self?.blockingQueueDrainCallback.mutate { $0.forEach { $0() } @@ -259,19 +285,6 @@ public final class JobRunner: JobRunnerType { } } - // TODO: Check if any of these are needed - //internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) - //fileprivate static var perSessionJobsCompleted: Atomic> = Atomic([]) - //private static var hasCompletedInitialBecomeActive: Atomic = Atomic(false) - //private static var shutdownBackgroundTask: Atomic = Atomic(nil) - //fileprivate static var canStartQueues: Atomic = Atomic(false) - //private static var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) - // - //fileprivate static var canStartNonBlockingQueue: Bool { - // blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue == true && - // blockingQueue.wrappedValue?.isRunning.wrappedValue != true - //} - // MARK: - Configuration public func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { @@ -279,164 +292,92 @@ public final class JobRunner: JobRunnerType { queues.wrappedValue[variant]?.setExecutor(executor, for: variant) } -// TODO: Double chekc this - //public func canStart(queue: JobQueue) -> Bool { - // return ( - // allowToExecuteJobs && - // appReadyToStartQueues.wrappedValue - // ) + public func canStart(queue: JobQueue?) -> Bool { + return ( + allowToExecuteJobs && + appReadyToStartQueues.wrappedValue && ( + queue?.type == .blocking || + canStartNonBlockingQueue + ) + ) + } - // TODO: Double check this - //public static func afterBlockingQueue(callback: @escaping () -> ()) { - // guard - // (blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue != true) || - // (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) - // else { return callback() } - // - // blockingQueueDrainCallback.mutate { $0.append(callback) } - //} - - // MARK: - Execution - - /// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start - /// the JobRunner - /// - /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` - /// is in the future then the job won't be started - //@discardableResult public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) -> 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 - // } - // - // // Wait until the transaction has been completed before updating the queue (to ensure anything - // // created during the transaction has been saved to the database before any corresponding jobs - // // are run) - // db.afterNextTransactionNested { _ in - // queues.wrappedValue[updatedJob.variant]?.add(updatedJob, canStartJob: canStartJob) - // - // // Don't start the queue if the job can't be started - // guard canStartJob else { return } - // - // queues.wrappedValue[updatedJob.variant]?.start() - // } - // - // return updatedJob - //} + public func afterBlockingQueue(callback: @escaping () -> ()) { + guard + (blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue != true) || + (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) + else { return callback() } + blockingQueueDrainCallback.mutate { $0.append(callback) } + } + // MARK: - State Management - - public func isCurrentlyRunning(_ job: Job?) -> Bool { - guard let job: Job = job else { return false } - - return !detailsFor(jobs: [job], state: .running).isEmpty - } - - public func hasJob(of variant: Job.Variant, inState state: JobRunner.JobState, with jobDetails: T) -> Bool { - guard let detailsData: Data = try? JSONEncoder().encode(jobDetails) else { return false } - - return detailsFor(state: state, variant: variant).values.contains(detailsData) - } - // // Wait until the transaction has been completed before updating the queue (to ensure anything - // // created during the transaction has been saved to the database before any corresponding jobs - // // are run) - // db.afterNextTransactionNested { _ in - // queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob) - // - // // Don't start the queue if the job can't be started - // guard canStartJob else { return } - // - // queues.wrappedValue[job.variant]?.start() - // } - //} - - ///// Insert a job before another job in the queue - ///// - ///// **Note:** This function assumes the relevant job queue is already running and as such **will not** start the queue if it isn't running - //@discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> (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")") - // 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 - // } - // - // // Wait until the transaction has been completed before updating the queue (to ensure anything - // // created during the transaction has been saved to the database before any corresponding jobs - // // are run) - // db.afterNextTransactionNested { _ in - // queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) - // } - // - // return (jobId, updatedJob) - //} - - public func detailsFor( + public func jobInfoFor( jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant? - ) -> [Int64: Data?] { - var result: [(Int64, Data?)] = [] + ) -> [Int64: JobRunner.JobInfo] { + var result: [(Int64, JobRunner.JobInfo)] = [] let targetKeys: [JobQueue.JobKey] = (jobs?.compactMap { JobQueue.JobKey($0) } ?? []) let targetVariants: [Job.Variant] = (variant.map { [$0] } ?? jobs?.map { $0.variant }) .defaulting(to: []) // Insert the state of any pending jobs if state.contains(.pending) { - func detailsFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, Data?)] { + func infoFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, JobRunner.JobInfo)] { return (queue?.pendingJobsQueue.wrappedValue .filter { variants.isEmpty || variants.contains($0.variant) } - .compactMap { job -> (Int64, Data?)? in + .compactMap { job -> (Int64, JobRunner.JobInfo)? in guard let jobKey: JobQueue.JobKey = JobQueue.JobKey(job) else { return nil } - guard !targetKeys.isEmpty else { return (jobKey.id, job.details) } + guard + targetKeys.isEmpty || + targetKeys.contains(jobKey) + else { return nil } - return (targetKeys.contains(jobKey) ? (jobKey.id, job.details) : nil) + return ( + jobKey.id, + JobRunner.JobInfo( + variant: job.variant, + threadId: job.threadId, + interactionId: job.interactionId, + detailsData: job.details + ) + ) }) .defaulting(to: []) } - result.append(contentsOf: detailsFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) + result.append(contentsOf: infoFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) queues.wrappedValue .filter { key, _ -> Bool in targetVariants.isEmpty || targetVariants.contains(key) } - .values - .forEach { queue in result.append(contentsOf: detailsFor(queue: queue, variants: targetVariants)) } + .map { _, queue in queue } + .asSet() + .forEach { queue in result.append(contentsOf: infoFor(queue: queue, variants: targetVariants)) } } // Insert the state of any running jobs if state.contains(.running) { - func detailsFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, Data?)] { - return (queue?.detailsForCurrentlyRunningJobs.wrappedValue - .filter { variants.isEmpty || variants.contains($0.key.variant) } - .compactMap { jobKey, details -> (Int64, Data?)? in - guard !targetKeys.isEmpty else { return (jobKey.id, details) } + func infoFor(queue: JobQueue?, variants: [Job.Variant]) -> [(Int64, JobRunner.JobInfo)] { + return (queue?.infoForAllCurrentlyRunningJobs() + .filter { variants.isEmpty || variants.contains($0.value.variant) } + .compactMap { jobId, info -> (Int64, JobRunner.JobInfo)? in + guard + targetKeys.isEmpty || + targetKeys.contains(JobQueue.JobKey(id: jobId, variant: info.variant)) + else { return nil } - return (targetKeys.contains(jobKey) ? (jobKey.id, details) : nil) + return (jobId, info) }) .defaulting(to: []) } - result.append(contentsOf: detailsFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) + result.append(contentsOf: infoFor(queue: blockingQueue.wrappedValue, variants: targetVariants)) queues.wrappedValue .filter { key, _ -> Bool in targetVariants.isEmpty || targetVariants.contains(key) } - .values - .forEach { queue in result.append(contentsOf: detailsFor(queue: queue, variants: targetVariants)) } + .map { _, queue in queue } + .asSet() + .forEach { queue in result.append(contentsOf: infoFor(queue: queue, variants: targetVariants)) } } return result @@ -445,7 +386,7 @@ public final class JobRunner: JobRunnerType { } } - public func appDidFinishLaunching(dependencies: Dependencies) { + public func appDidFinishLaunching(using dependencies: Dependencies) { // Flag that the JobRunner can start it's queues appReadyToStartQueues.mutate { $0 = true } @@ -484,13 +425,11 @@ public final class JobRunner: JobRunnerType { } .defaulting(to: ([], [])) - guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { return } - // Add and start any blocking jobs blockingQueue.wrappedValue?.appDidFinishLaunching( with: jobsToRun.blocking, canStart: true, - dependencies: dependencies + using: dependencies ) // Add any non-blocking jobs (we don't start these incase there are blocking "on active" @@ -499,13 +438,18 @@ public final class JobRunner: JobRunnerType { let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue jobsByVariant.forEach { variant, jobs in - jobQueues[variant]?.appDidFinishLaunching(with: jobs, canStart: false, dependencies: dependencies) + jobQueues[variant]?.appDidFinishLaunching( + with: jobs, + canStart: false, + using: dependencies + ) } } - public func appDidBecomeActive(dependencies: Dependencies) { - // Flag that the JobRunner can start it's queues + public func appDidBecomeActive(using dependencies: Dependencies) { + // Flag that the JobRunner can start it's queues and start queueing non-launch jobs appReadyToStartQueues.mutate { $0 = true } + appHasBecomeActive.mutate { $0 = true } // If we have a running "sutdownBackgroundTask" then we want to cancel it as otherwise it // can result in the database being suspended and us being unable to interact with it at all @@ -535,27 +479,37 @@ public final class JobRunner: JobRunnerType { guard !jobsToRun.isEmpty else { if !blockingQueueIsRunning { - jobQueues.forEach { _, queue in queue.start(dependencies: dependencies) } + jobQueues.map { _, queue in queue }.asSet().forEach { $0.start(using: dependencies) } } return } // Add and start any non-blocking jobs (if there are no blocking jobs) + // + // We only want to trigger the queue to start once so we need to consolidate the + // queues to list of jobs (as queues can handle multiple job variants), this means + // that 'onActive' jobs will be queued before any standard jobs let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.grouped(by: \.variant) - jobQueues.forEach { variant, queue in - queue.appDidBecomeActive( - with: (jobsByVariant[variant] ?? []), - canStart: !blockingQueueIsRunning, - dependencies: dependencies - ) - } + jobQueues + .reduce(into: [:]) { result, variantAndQueue in + result[variantAndQueue.value] = (result[variantAndQueue.value] ?? []) + .appending(contentsOf: (jobsByVariant[variantAndQueue.key] ?? [])) + } + .forEach { queue, jobs in + queue.appDidBecomeActive( + with: jobs, + canStart: !blockingQueueIsRunning, + using: dependencies + ) + } + self.hasCompletedInitialBecomeActive.mutate { $0 = true } } - public func startNonBlockingQueues(dependencies: Dependencies) { - queues.wrappedValue.forEach { _, queue in - queue.start(dependencies: dependencies) + public func startNonBlockingQueues(using dependencies: Dependencies) { + queues.wrappedValue.map { _, queue in queue }.asSet().forEach { queue in + queue.start(using: dependencies) } } @@ -567,10 +521,12 @@ public final class JobRunner: JobRunnerType { // rescheduling themselves while in the background, when the app restarts or becomes active // the JobRunenr will update this flag) appReadyToStartQueues.mutate { $0 = false } + appHasBecomeActive.mutate { $0 = false } // Stop all queues except for the one containing the `exceptForVariant` queues.wrappedValue - .values + .map { _, queue in queue } + .asSet() .filter { queue -> Bool in guard let exceptForVariant: Job.Variant = exceptForVariant else { return true } @@ -619,66 +575,71 @@ public final class JobRunner: JobRunnerType { // MARK: - Execution - public func add( + @discardableResult public func add( _ db: Database, job: Job?, canStartJob: Bool, - dependencies: Dependencies - ) { + 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 + return nil } guard !canStartJob || updatedJob.id != nil else { SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") - return + return nil } - queues.wrappedValue[updatedJob.variant]?.add(db, job: updatedJob, canStartJob: canStartJob, dependencies: dependencies) + // 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(updatedJob) else { return updatedJob } + + queues.wrappedValue[updatedJob.variant]?.add(db, job: updatedJob, canStartJob: canStartJob, using: dependencies) // Don't start the queue if the job can't be started - guard canStartJob else { return } + guard canStartJob else { return updatedJob } // Start the job runner if needed db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(updatedJob.variant)") { [weak self] _ in - self?.queues.wrappedValue[updatedJob.variant]?.start(dependencies: dependencies) + self?.queues.wrappedValue[updatedJob.variant]?.start(using: dependencies) } + + return updatedJob } public func upsert( _ db: Database, job: Job?, canStartJob: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { guard let job: Job = job else { return } // Ignore null jobs guard job.id != nil else { - add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) + add(db, job: job, canStartJob: canStartJob, using: dependencies) return } - queues.wrappedValue[job.variant]?.upsert(db, job: job, canStartJob: canStartJob, dependencies: dependencies) + // 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 } + + queues.wrappedValue[job.variant]?.upsert(db, job: job, 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(dependencies: dependencies) + self?.queues.wrappedValue[job.variant]?.start(using: dependencies) } } - - //public static func infoForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: JobInfo] { - // return (queues.wrappedValue[variant]?.infoForAllCurrentlyRunningJobs()) - // .defaulting(to: [:]) - //} @discardableResult public func insert( _ db: Database, job: Job?, - before otherJob: Job, - dependencies: Dependencies + before otherJob: Job ) -> (Int64, Job)? { switch job?.behaviour { case .recurringOnActive, .recurringOnLaunch, .runOnceNextLaunch: @@ -698,8 +659,7 @@ public final class JobRunner: JobRunnerType { return nil } - queues.wrappedValue[updatedJob.variant]? - .insert(updatedJob, before: otherJob, dependencies: dependencies) + queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) return (jobId, updatedJob) } @@ -752,11 +712,20 @@ public final class JobRunner: JobRunnerType { let maxBackoff: Double = 10 * 60 // 10 minutes return 0.25 * min(maxBackoff, pow(2, Double(job.failureCount))) } + + fileprivate func canAddToQueue(_ job: Job) -> Bool { + // We can only start the job if it's an "on launch" job or the app has become active + return ( + job.behaviour == .runOnceNextLaunch || + job.behaviour == .recurringOnLaunch || + appHasBecomeActive.wrappedValue + ) + } } // MARK: - JobQueue -public final class JobQueue { +public final class JobQueue: Hashable { fileprivate enum QueueType: Hashable { case blocking case general(number: Int) @@ -792,33 +761,21 @@ public final class JobQueue { static func create( queue: JobQueue, timestamp: TimeInterval, - dependencies: Dependencies + using dependencies: Dependencies ) -> Trigger? { - guard !SNUtilitiesKit.isRunningTests else { - /// When running unit tests don't schedule a proper Timer, use a while loop instead and base it on the `fixedTime` - /// value instead of `dependencies.date` to simplify things - DispatchQueue.global(qos: .default).async { [weak queue] in - while dependencies.fixedTime < Int(timestamp) { - Thread.sleep(forTimeInterval: 0.01) // Wait for 10ms - } - - queue?.start(dependencies: dependencies) - } - return nil - } - /// Setup the trigger (wait at least 1 second before triggering) /// /// **Note:** We use the `Timer.scheduledTimerOnMainThread` method because running a timer /// on our random queue threads results in the timer never firing, the `start` method will redirect itself to /// the correct thread let trigger: Trigger = Trigger() - trigger.fireTimestamp = max(1, (timestamp - dependencies.date.timeIntervalSince1970)) + trigger.fireTimestamp = max(1, (timestamp - dependencies.dateNow.timeIntervalSince1970)) trigger.timer = Timer.scheduledTimerOnMainThread( withTimeInterval: trigger.fireTimestamp, repeats: false, + using: dependencies, block: { [weak queue] _ in - queue?.start(dependencies: dependencies) + queue?.start(using: dependencies) } ) return trigger @@ -835,6 +792,11 @@ public final class JobQueue { fileprivate let id: Int64 fileprivate let variant: Job.Variant + fileprivate init(id: Int64, variant: Job.Variant) { + self.id = id + self.variant = variant + } + fileprivate init?(_ job: Job?) { guard let id: Int64 = job?.id, let variant: Job.Variant = job?.variant else { return nil } @@ -845,17 +807,14 @@ public final class JobQueue { private static let deferralLoopThreshold: Int = 3 - private let type: QueueType + private let id: UUID = UUID() + fileprivate let type: QueueType private let executionType: ExecutionType private let qosClass: DispatchQoS private let queueKey: DispatchSpecificKey = DispatchSpecificKey() private let queueContext: String - - /// The specific types of jobs this queue manages, if this is left empty it will handle all jobs not handled by other queues fileprivate let jobVariants: [Job.Variant] - fileprivate var onQueueDrained: (() -> ())? - private lazy var internalQueue: DispatchQueue = { let result: DispatchQueue = DispatchQueue( label: self.queueContext, @@ -870,17 +829,18 @@ public final class JobQueue { }() private var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) - private var nextTrigger: Atomic = Atomic(nil) + fileprivate var canStart: ((JobQueue?) -> Bool)? + fileprivate var onQueueDrained: (() -> ())? fileprivate var hasStartedAtLeastOnce: Atomic = Atomic(false) fileprivate var isRunning: Atomic = Atomic(false) fileprivate var pendingJobsQueue: Atomic<[Job]> = Atomic([]) - fileprivate var jobsCurrentlyRunning: Atomic> = Atomic([]) - // TODO: Check these - fileprivate var detailsForCurrentlyRunningJobs: Atomic<[JobKey: Data?]> = Atomic([:]) + + private var nextTrigger: Atomic = Atomic(nil) + private var jobCallbacks: Atomic<[Int64: [(JobRunner.JobResult) -> ()]]> = Atomic([:]) private var currentlyRunningJobIds: Atomic> = Atomic([]) private var currentlyRunningJobInfo: Atomic<[Int64: JobRunner.JobInfo]> = Atomic([:]) - private var jobCallbacks: Atomic<[Int64: [(JobRunner.JobResult) -> ()]]> = Atomic([:]) private var deferLoopTracker: Atomic<[Int64: (count: Int, times: [TimeInterval])]> = Atomic([:]) + private let maxDeferralsPerSecond: Int fileprivate var hasPendingJobs: Bool { !pendingJobsQueue.wrappedValue.isEmpty } @@ -890,15 +850,25 @@ public final class JobQueue { type: QueueType, executionType: ExecutionType = .serial, qos: DispatchQoS, - jobVariants: [Job.Variant], - onQueueDrained: (() -> ())? = nil + isTestingJobRunner: Bool, + jobVariants: [Job.Variant] ) { self.type = type self.executionType = executionType self.queueContext = "JobQueue-\(type.name)" self.qosClass = qos + self.maxDeferralsPerSecond = (isTestingJobRunner ? 10 : 1) // Allow for tripping the defer loop in tests self.jobVariants = jobVariants - self.onQueueDrained = onQueueDrained + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + id.hash(into: &hasher) + } + + public static func == (lhs: JobQueue, rhs: JobQueue) -> Bool { + return (lhs.id == rhs.id) } // MARK: - Configuration @@ -909,12 +879,17 @@ public final class JobQueue { // MARK: - Execution - fileprivate func add(_ db: Database, job: Job, canStartJob: Bool = true, dependencies: Dependencies) { + fileprivate func add( + _ db: Database, + job: Job, + canStartJob: Bool, + using dependencies: Dependencies + ) { // Check if the job should be added to the queue guard canStartJob, job.behaviour != .runOnceNextLaunch, - job.nextRunTimestamp <= dependencies.date.timeIntervalSince1970 + job.nextRunTimestamp <= dependencies.dateNow.timeIntervalSince1970 else { return } guard job.id != nil else { SNLog("[JobRunner] Prevented attempt to add \(job.variant) job without id to queue") @@ -929,7 +904,7 @@ public final class JobQueue { // Ensure that the database commit has completed and then trigger the next job to run (need // to ensure any interactions have been correctly inserted first) db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Add: \(job.variant)") { [weak self] _ in - self?.runNextJob(dependencies: dependencies) + self?.runNextJob(using: dependencies) } } @@ -938,7 +913,12 @@ public final class JobQueue { /// /// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp` /// is in the future then the job won't be started - fileprivate func upsert(_ db: Database, job: Job, canStartJob: Bool = true, dependencies: Dependencies) { + fileprivate func upsert( + _ db: Database, + job: Job, + canStartJob: Bool, + using dependencies: Dependencies + ) { guard let jobId: Int64 = job.id else { SNLog("[JobRunner] Prevented attempt to upsert \(job.variant) job without id to queue") return @@ -961,10 +941,10 @@ public final class JobQueue { // If we didn't update an existing job then we need to add it to the pendingJobsQueue guard !didUpdateExistingJob else { return } - add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) + add(db, job: job, canStartJob: canStartJob, using: dependencies) } - fileprivate func insert(_ job: Job, before otherJob: Job, dependencies: Dependencies) { + fileprivate func insert(_ job: Job, before otherJob: Job) { guard job.id != nil else { SNLog("[JobRunner] Prevented attempt to insert \(job.variant) job without id to queue") return @@ -987,21 +967,22 @@ public final class JobQueue { fileprivate func appDidFinishLaunching( with jobs: [Job], canStart: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { pendingJobsQueue.mutate { $0.append(contentsOf: jobs) } // Start the job runner if needed if canStart && !isRunning.wrappedValue { - start(dependencies: dependencies) + start(using: dependencies) } } fileprivate func appDidBecomeActive( with jobs: [Job], canStart: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { + let currentlyRunningJobIds: Set = currentlyRunningJobIds.wrappedValue pendingJobsQueue.mutate { queue in // Avoid re-adding jobs to the queue that are already in it (this can @@ -1018,39 +999,28 @@ public final class JobQueue { // Start the job runner if needed if canStart && !isRunning.wrappedValue { - start(dependencies: dependencies) + start(using: dependencies) } } - //fileprivate func isCurrentlyRunning(_ jobId: Int64) -> Bool { - // return currentlyRunningJobIds.wrappedValue.contains(jobId) - //} - // - //fileprivate func infoForAllCurrentlyRunningJobs() -> [Int64: JobRunner.JobInfo] { - // return currentlyRunningJobInfo.wrappedValue - //} + fileprivate func infoForAllCurrentlyRunningJobs() -> [Int64: JobRunner.JobInfo] { + return currentlyRunningJobInfo.wrappedValue + } fileprivate func afterCurrentlyRunningJob(_ jobId: Int64, callback: @escaping (JobRunner.JobResult) -> ()) { - guard jobsCurrentlyRunning.wrappedValue.contains(jobId) else { - callback(.notFound) - return - } + guard currentlyRunningJobIds.wrappedValue.contains(jobId) else { return callback(.notFound) } jobCallbacks.mutate { jobCallbacks in jobCallbacks[jobId] = (jobCallbacks[jobId] ?? []).appending(callback) } } - //fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { - // guard let detailsData: Data = detailsData else { return false } - // - // let pendingJobs: [Job] = pendingJobsQueue.wrappedValue fileprivate func hasPendingOrRunningJobWith( threadId: String? = nil, interactionId: Int64? = nil, detailsData: Data? = nil ) -> Bool { - let pendingJobs: [Job] = queue.wrappedValue + let pendingJobs: [Job] = pendingJobsQueue.wrappedValue let currentlyRunningJobInfo: [Int64: JobRunner.JobInfo] = currentlyRunningJobInfo.wrappedValue var possibleJobIds: Set = Set(currentlyRunningJobInfo.keys) .inserting(contentsOf: pendingJobs.compactMap { $0.id }.asSet()) @@ -1116,29 +1086,17 @@ public final class JobQueue { fileprivate func start( forceWhenAlreadyRunning: Bool = false, - dependencies: Dependencies + using dependencies: Dependencies ) { // Only start if the JobRunner is allowed to start the queue - guard dependencies.jobRunner.canStart(queue: self) else { return } + guard canStart?(self) == true else { return } guard forceWhenAlreadyRunning || !isRunning.wrappedValue else { return } -// TODO: Check this - // We only want the JobRunner to run in the main app - //guard - // HasAppContext() && - // CurrentAppContext().isMainApp && - // !CurrentAppContext().isRunningTests && - // JobRunner.canStartQueues.wrappedValue && - // ( - // type == .blocking || - // JobRunner.canStartNonBlockingQueue - // ) - //else { return } // The JobRunner runs synchronously we need to ensure this doesn't start // on the main thread (if it is on the main thread then swap to a different thread) - guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { - internalQueue.async { [weak self] in - self?.start(dependencies: dependencies) + guard DispatchQueue.with(key: queueKey, matches: queueContext, using: dependencies) else { + internalQueue.async(using: dependencies) { [weak self] in + self?.start(using: dependencies) } return } @@ -1153,11 +1111,9 @@ public final class JobQueue { hasStartedAtLeastOnce.mutate { $0 = true } // Get any pending jobs - let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue + let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue let jobsAlreadyInQueue: Set = pendingJobsQueue.wrappedValue.compactMap { $0.id }.asSet() - let jobsToRun: [Job] = dependencies.storage.read { db in -// TODO: Check this - //let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue + let jobsToRun: [Job] = dependencies.storage.read(using: dependencies) { db in try Job .filterPendingJobs( variants: jobVariants, @@ -1183,16 +1139,16 @@ public final class JobQueue { guard jobCount > 0 else { if jobIdsAlreadyRunning.isEmpty { isRunning.mutate { $0 = false } - scheduleNextSoonestJob(dependencies: dependencies) + scheduleNextSoonestJob(using: dependencies) } return } // Run the first job in the pendingJobsQueue if !wasAlreadyRunning { - SNLog("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") + SNLogNotTests("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") } - runNextJob(dependencies: dependencies) + runNextJob(using: dependencies) } fileprivate func stopAndClearPendingJobs() { @@ -1201,14 +1157,14 @@ public final class JobQueue { deferLoopTracker.mutate { $0 = [:] } } - private func runNextJob(dependencies: Dependencies) { + private func runNextJob(using dependencies: Dependencies) { // Ensure the queue is running (if we've stopped the queue then we shouldn't start the next job) guard isRunning.wrappedValue else { return } // Ensure this is running on the correct queue - guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + guard DispatchQueue.with(key: queueKey, matches: queueContext, using: dependencies) else { + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } return } @@ -1220,7 +1176,7 @@ public final class JobQueue { // Always attempt to schedule the next soonest job (otherwise if enough jobs get started in rapid // succession then pending/failed jobs in the database may never get re-started in a concurrent queue) - scheduleNextSoonestJob(dependencies: dependencies) + scheduleNextSoonestJob(using: dependencies) return } guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else { @@ -1229,7 +1185,7 @@ public final class JobQueue { nextJob, error: JobRunnerError.executorMissing, permanentFailure: true, - dependencies: dependencies + using: dependencies ) return } @@ -1239,7 +1195,7 @@ public final class JobQueue { nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true, - dependencies: dependencies + using: dependencies ) return } @@ -1249,7 +1205,7 @@ public final class JobQueue { nextJob, error: JobRunnerError.requiredInteractionIdMissing, permanentFailure: true, - dependencies: dependencies + using: dependencies ) return } @@ -1259,19 +1215,19 @@ public final class JobQueue { nextJob, error: JobRunnerError.jobIdMissing, permanentFailure: false, - dependencies: dependencies + using: dependencies ) return } // If the 'nextRunTimestamp' for the job is in the future then don't run it yet - guard nextJob.nextRunTimestamp <= dependencies.date.timeIntervalSince1970 else { - handleJobDeferred(nextJob, dependencies: dependencies) + guard nextJob.nextRunTimestamp <= dependencies.dateNow.timeIntervalSince1970 else { + handleJobDeferred(nextJob, using: dependencies) return } // Check if the next job has any dependencies - let dependencyInfo: (expectedCount: Int, jobs: Set) = dependencies.storage.read { db in + let dependencyInfo: (expectedCount: Int, jobs: Set) = dependencies.storage.read(using: dependencies) { db in let expectedDependencies: Set = try JobDependencies .filter(JobDependencies.Columns.jobId == nextJob.id) .fetchSet(db) @@ -1289,7 +1245,7 @@ public final class JobQueue { nextJob, error: JobRunnerError.missingDependencies, permanentFailure: true, - dependencies: dependencies + using: dependencies ) return } @@ -1301,9 +1257,7 @@ public final class JobQueue { /// /// **Note:** We don't add the current job back the the queue because it should only be re-added if it's dependencies /// are successfully completed - let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys.map { $0.id }) - // TODO: CHeck this - //let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) + let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) let dependencyJobsNotCurrentlyRunning: [Job] = dependencyInfo.jobs .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } @@ -1313,7 +1267,7 @@ public final class JobQueue { .filter { !dependencyJobsNotCurrentlyRunning.contains($0) } .inserting(contentsOf: dependencyJobsNotCurrentlyRunning, at: 0) } - handleJobDeferred(nextJob, dependencies: dependencies) + handleJobDeferred(nextJob, using: dependencies) return } @@ -1335,14 +1289,13 @@ public final class JobQueue { currentlyRunningJobInfo = currentlyRunningJobInfo.setting( nextJob.id, JobRunner.JobInfo( + variant: nextJob.variant, threadId: nextJob.threadId, interactionId: nextJob.interactionId, detailsData: nextJob.details ) ) } -// TODO: Check this - detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(JobKey(nextJob), nextJob.details) } SNLog("[JobRunner] \(queueContext) started \(nextJob.variant) job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)") /// As it turns out Combine doesn't plat too nicely with concurrent Dispatch Queues, in Combine events are dispatched asynchronously to @@ -1369,23 +1322,21 @@ public final class JobQueue { success: handleJobSucceeded, failure: handleJobFailed, deferred: handleJobDeferred, - dependencies: dependencies + using: dependencies ) // If this queue executes concurrently and there are still jobs remaining then immediately attempt // to start the next job if executionType == .concurrent && numJobsRemaining > 0 { - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } } - private func scheduleNextSoonestJob(dependencies: Dependencies) { - let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue - let nextJobTimestamp: TimeInterval? = dependencies.storage.read { db in - // TODO: Check this - //let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue + private func scheduleNextSoonestJob(using dependencies: Dependencies) { + let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue + let nextJobTimestamp: TimeInterval? = dependencies.storage.read(using: dependencies) { db in try Job .filterPendingJobs( variants: jobVariants, @@ -1400,21 +1351,15 @@ public final class JobQueue { // If there are no remaining jobs or the JobRunner isn't allowed to start any queues then trigger // the 'onQueueDrained' callback and stop - guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, dependencies.jobRunner.canStart(queue: self) else { - if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { - // TODO: Chgeck this - //if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty { + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, canStart?(self) == true else { + if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty { self.onQueueDrained?() } return } // If the next job isn't scheduled in the future then just restart the JobRunner immediately - let secondsUntilNextJob: TimeInterval = { - guard !SNUtilitiesKit.isRunningTests else { return (nextJobTimestamp - TimeInterval(dependencies.fixedTime)) } - - return (nextJobTimestamp - dependencies.date.timeIntervalSince1970) - }() + let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - dependencies.dateNow.timeIntervalSince1970) guard secondsUntilNextJob > 0 else { // Only log that the queue is getting restarted if this queue had actually been about to stop @@ -1429,8 +1374,8 @@ public final class JobQueue { // Trigger the 'start' function to load in any pending jobs that aren't already in the // queue (for concurrent queues we want to force them to load in pending jobs and add // them to the queue regardless of whether the queue is already running) - internalQueue.async { [weak self] in - self?.start(forceWhenAlreadyRunning: (self?.executionType == .concurrent), dependencies: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.start(forceWhenAlreadyRunning: (self?.executionType == .concurrent), using: dependencies) } return } @@ -1442,7 +1387,7 @@ public final class JobQueue { SNLog("[JobRunner] Stopping \(queueContext) until next job in \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")") nextTrigger.mutate { trigger in trigger?.invalidate() // Need to invalidate the old trigger to prevent a memory leak - trigger = Trigger.create(queue: self, timestamp: nextJobTimestamp, dependencies: dependencies) + trigger = Trigger.create(queue: self, timestamp: nextJobTimestamp, using: dependencies) } } @@ -1452,17 +1397,17 @@ public final class JobQueue { private func handleJobSucceeded( _ job: Job, shouldStop: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { /// Retrieve the dependant jobs first (the `JobDependecies` table has cascading deletion when the original `Job` is /// removed so we need to retrieve these records before that happens) let dependantJobs: [Job] = dependencies.storage - .read { db in try job.dependantJobs.fetchAll(db) } + .read(using: dependencies) { db in try job.dependantJobs.fetchAll(db) } .defaulting(to: []) switch job.behaviour { case .runOnce, .runOnceNextLaunch: - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in /// Since this job has been completed we can update the dependencies so other job that were dependant /// on this one can be run _ = try JobDependencies @@ -1473,7 +1418,7 @@ public final class JobQueue { } case .recurring where shouldStop == true: - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in /// Since this job has been completed we can update the dependencies so other job that were dependant /// on this one can be run _ = try JobDependencies @@ -1485,16 +1430,16 @@ public final class JobQueue { /// For `recurring` jobs which have already run, they should automatically run again but we want at least 1 second /// to pass before doing so - the job itself should really update it's own `nextRunTimestamp` (this is just a safety net) - case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970: + case .recurring where job.nextRunTimestamp <= dependencies.dateNow.timeIntervalSince1970: guard let jobId: Int64 = job.id else { break } - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in _ = try Job .filter(id: jobId) .updateAll( db, Job.Columns.failureCount.set(to: 0), - Job.Columns.nextRunTimestamp.set(to: (Date().timeIntervalSince1970 + 1)) + Job.Columns.nextRunTimestamp.set(to: (dependencies.dateNow.timeIntervalSince1970 + 1)) ) } @@ -1507,7 +1452,7 @@ public final class JobQueue { job.nextRunTimestamp > TimeInterval.leastNonzeroMagnitude else { break } - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in _ = try Job .filter(id: jobId) .updateAll( @@ -1527,9 +1472,7 @@ public final class JobQueue { /// **Note:** If any of these `dependantJobs` have other dependencies then when they attempt to start they will be /// removed from the queue, replaced by their dependencies if !dependantJobs.isEmpty { - let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys.map { $0.id }) - // TODO: CHeck this - //let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) + let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) let dependantJobsNotCurrentlyRunning: [Job] = dependantJobs .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } @@ -1542,9 +1485,9 @@ public final class JobQueue { } // Perform job cleanup and start the next job - performCleanUp(for: job, result: .succeeded) - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + performCleanUp(for: job, result: .succeeded, using: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } @@ -1554,14 +1497,14 @@ public final class JobQueue { _ job: Job, error: Error?, permanentFailure: Bool, - dependencies: Dependencies + using dependencies: Dependencies ) { - guard dependencies.storage.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { + guard dependencies.storage.read(using: dependencies, { db in try Job.exists(db, id: job.id ?? -1) }) == true else { SNLog("[JobRunner] \(queueContext) \(job.variant) job canceled") - performCleanUp(for: job, result: .failed) + performCleanUp(for: job, result: .failed, using: dependencies) - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } return } @@ -1583,7 +1526,8 @@ public final class JobQueue { performCleanUp( for: job, result: .failed, - shouldTriggerCallbacks: wasPossibleDeferralLoop + shouldTriggerCallbacks: wasPossibleDeferralLoop, + using: dependencies ) // Only add it back to the queue if it wasn't a deferral loop @@ -1591,19 +1535,19 @@ public final class JobQueue { pendingJobsQueue.mutate { $0.insert(job, at: 0) } } - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } return } // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) - let nextRunTimestamp: TimeInterval = (dependencies.date.timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) + let nextRunTimestamp: TimeInterval = (dependencies.dateNow.timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) var dependantJobIds: [Int64] = [] var failureText: String = "failed" - dependencies.storage.write { db in + dependencies.storage.write(using: dependencies) { db in /// Retrieve a list of dependant jobs so we can clear them from the queue dependantJobIds = try job.dependantJobs .select(.id) @@ -1662,9 +1606,9 @@ public final class JobQueue { } SNLog("[JobRunner] \(queueContext) \(job.variant) job \(failureText)") - performCleanUp(for: job, result: .failed) - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + performCleanUp(for: job, result: .failed, using: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } @@ -1672,7 +1616,7 @@ public final class JobQueue { /// on other jobs, and it should automatically manage those dependencies) public func handleJobDeferred( _ job: Job, - dependencies: Dependencies + using dependencies: Dependencies ) { var stuckInDeferLoop: Bool = false @@ -1680,15 +1624,15 @@ public final class JobQueue { guard let lastRecord: (count: Int, times: [TimeInterval]) = $0[job.id] else { $0 = $0.setting( job.id, - (1, [dependencies.date.timeIntervalSince1970]) + (1, [dependencies.dateNow.timeIntervalSince1970]) ) return } - let timeNow: TimeInterval = dependencies.date.timeIntervalSince1970 + let timeNow: TimeInterval = dependencies.dateNow.timeIntervalSince1970 stuckInDeferLoop = ( lastRecord.count >= JobQueue.deferralLoopThreshold && - (timeNow - lastRecord.times[0]) < CGFloat(lastRecord.count) + (timeNow - lastRecord.times[0]) < CGFloat(lastRecord.count * maxDeferralsPerSecond) ) $0 = $0.setting( @@ -1715,25 +1659,27 @@ public final class JobQueue { job, error: JobRunnerError.possibleDeferralLoop, permanentFailure: false, - dependencies: dependencies + using: dependencies ) return } - performCleanUp(for: job, result: .deferred) - internalQueue.async { [weak self] in - self?.runNextJob(dependencies: dependencies) + performCleanUp(for: job, result: .deferred, using: dependencies) + internalQueue.async(using: dependencies) { [weak self] in + self?.runNextJob(using: dependencies) } } - private func performCleanUp(for job: Job, result: JobRunner.JobResult, shouldTriggerCallbacks: Bool = true) { + private func performCleanUp( + for job: Job, + result: JobRunner.JobResult, + shouldTriggerCallbacks: Bool = true, + using dependencies: Dependencies + ) { // The job is removed from the queue before it runs so all we need to to is remove it // from the 'currentlyRunning' set - jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } - detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: JobKey(job)) } - // TODO: CHeck this - //currentlyRunningJobIds.mutate { $0 = $0.removing(job.id) } - //currentlyRunningJobInfo.mutate { $0 = $0.removingValue(forKey: job.id) } + currentlyRunningJobIds.mutate { $0 = $0.removing(job.id) } + currentlyRunningJobInfo.mutate { $0 = $0.removingValue(forKey: job.id) } guard shouldTriggerCallbacks else { return } @@ -1744,7 +1690,7 @@ public final class JobQueue { jobCallbacks = jobCallbacks.removingValue(forKey: job.id) } - DispatchQueue.global(qos: .default).async { + DispatchQueue.global(qos: .default).async(using: dependencies) { jobCallbacksToRun.forEach { $0(result) } } } @@ -1761,12 +1707,12 @@ public extension JobRunner { instance.setExecutor(executor, for: variant) } - static func appDidFinishLaunching(dependencies: Dependencies = Dependencies()) { - instance.appDidFinishLaunching(dependencies: dependencies) + static func appDidFinishLaunching(using dependencies: Dependencies = Dependencies()) { + instance.appDidFinishLaunching(using: dependencies) } - static func appDidBecomeActive(dependencies: Dependencies = Dependencies()) { - instance.appDidBecomeActive(dependencies: dependencies) + static func appDidBecomeActive(using dependencies: Dependencies = Dependencies()) { + instance.appDidBecomeActive(using: dependencies) } static func afterBlockingQueue(callback: @escaping () -> ()) { @@ -1782,8 +1728,8 @@ public extension JobRunner { _ db: Database, job: Job?, canStartJob: Bool = true, - dependencies: Dependencies = Dependencies() - ) { instance.add(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } + using dependencies: Dependencies = Dependencies() + ) { instance.add(db, job: job, canStartJob: canStartJob, using: dependencies) } /// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start /// the JobRunner @@ -1794,15 +1740,14 @@ public extension JobRunner { _ db: Database, job: Job?, canStartJob: Bool = true, - dependencies: Dependencies = Dependencies() - ) { instance.upsert(db, job: job, canStartJob: canStartJob, dependencies: dependencies) } + using dependencies: Dependencies = Dependencies() + ) { instance.upsert(db, job: job, canStartJob: canStartJob, using: dependencies) } @discardableResult static func insert( _ db: Database, job: Job?, - before otherJob: Job, - dependencies: Dependencies = Dependencies() - ) -> (Int64, Job)? { instance.insert(db, job: job, before: otherJob, dependencies: dependencies) } + before otherJob: Job + ) -> (Int64, Job)? { instance.insert(db, job: job, before: otherJob) } /// Calling this will clear the JobRunner queues and stop it from running new jobs, any currently executing jobs will continue to run /// though (this means if we suspend the database it's likely that any currently running jobs will fail to complete and fail to record their diff --git a/SessionUtilitiesKit/Networking/NetworkType.swift b/SessionUtilitiesKit/Networking/NetworkType.swift new file mode 100644 index 000000000..7ded00ee3 --- /dev/null +++ b/SessionUtilitiesKit/Networking/NetworkType.swift @@ -0,0 +1,42 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine + +public protocol NetworkType { + func send(_ request: Network.RequestType) -> AnyPublisher<(ResponseInfoType, T), Error> +} + +public class Network: NetworkType { + public struct RequestType { + public let id: String + public let url: String? + public let method: String? + public let headers: [String: String]? + public let body: Data? + public let args: [Any?] + public let generatePublisher: () -> AnyPublisher<(ResponseInfoType, T), Error> + + public init( + id: String, + url: String? = nil, + method: String? = nil, + headers: [String: String]? = nil, + body: Data? = nil, + args: [Any?] = [], + generatePublisher: @escaping () -> AnyPublisher<(ResponseInfoType, T), Error> + ) { + self.id = id + self.url = url + self.method = method + self.headers = headers + self.body = body + self.args = args + self.generatePublisher = generatePublisher + } + } + + public func send(_ request: RequestType) -> AnyPublisher<(ResponseInfoType, T), Error> { + return request.generatePublisher() + } +} diff --git a/SessionUtilitiesKit/Networking/SessionNetwork.swift b/SessionUtilitiesKit/Networking/SessionNetwork.swift deleted file mode 100644 index 04969735d..000000000 --- a/SessionUtilitiesKit/Networking/SessionNetwork.swift +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation diff --git a/SessionUtilitiesKit/Utilities/Crypto.swift b/SessionUtilitiesKit/Utilities/Crypto.swift new file mode 100644 index 000000000..2598ac56b --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Crypto.swift @@ -0,0 +1,92 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import CryptoKit +import Clibsodium +import Sodium +import Curve25519Kit + +// MARK: - CryptoType + +public protocol CryptoType { + func size(_ size: Crypto.Size) -> Int + func perform(_ action: Crypto.Action) throws -> Array + func verify(_ verification: Crypto.Verification) -> Bool + func generate(_ keyPairType: Crypto.KeyPairType) -> KeyPair? +} + +// MARK: - CryptoError + +public enum CryptoError: LocalizedError { + case failedToGenerateOutput + + public var errorDescription: String? { + switch self { + case .failedToGenerateOutput: return "Failed to generate output." + } + } +} + +// MARK: - Crypto + +public struct Crypto: CryptoType { + public struct Size { + public let id: String + public let args: [Any?] + let get: () -> Int + + public init(id: String, args: [Any?] = [], get: @escaping () -> Int) { + self.id = id + self.args = args + self.get = get + } + } + + public struct Action { + public let id: String + public let args: [Any?] + let perform: () throws -> Array + + public init(id: String, args: [Any?] = [], perform: @escaping () throws -> Array) { + self.id = id + self.args = args + self.perform = perform + } + + public init(id: String, args: [Any?] = [], perform: @escaping () -> Array?) { + self.id = id + self.args = args + self.perform = { try perform() ?? { throw CryptoError.failedToGenerateOutput }() } + } + } + + public struct Verification { + public let id: String + public let args: [Any?] + let verify: () -> Bool + + public init(id: String, args: [Any?] = [], verify: @escaping () -> Bool) { + self.id = id + self.args = args + self.verify = verify + } + } + + public struct KeyPairType { + public let id: String + public let args: [Any?] + let generate: () -> KeyPair? + + public init(id: String, args: [Any?] = [], generate: @escaping () -> KeyPair?) { + self.id = id + self.args = args + self.generate = generate + } + } + + public init() {} + public func size(_ size: Crypto.Size) -> Int { return size.get() } + public func perform(_ action: Crypto.Action) throws -> Array { return try action.perform() } + public func verify(_ verification: Crypto.Verification) -> Bool { return verification.verify() } + public func generate(_ keyPairType: Crypto.KeyPairType) -> KeyPair? { return keyPairType.generate() } +} diff --git a/SessionUtilitiesKit/Utilities/CryptoType.swift b/SessionUtilitiesKit/Utilities/CryptoType.swift deleted file mode 100644 index 04969735d..000000000 --- a/SessionUtilitiesKit/Utilities/CryptoType.swift +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. - -import Foundation diff --git a/SessionUtilitiesKit/Utilities/DispatchQueue+Utilities.swift b/SessionUtilitiesKit/Utilities/DispatchQueue+Utilities.swift new file mode 100644 index 000000000..6bfa322f3 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/DispatchQueue+Utilities.swift @@ -0,0 +1,35 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension DispatchQueue { + func async( + group: DispatchGroup? = nil, + qos: DispatchQoS = .unspecified, + flags: DispatchWorkItemFlags = [], + using dependencies: Dependencies, + execute work: @escaping () -> Void + ) { + guard !dependencies.forceSynchronous else { return work() } + + return self.async(group: group, qos: qos, flags: flags, execute: work) + } + + func asyncAfter( + deadline: DispatchTime, + qos: DispatchQoS = .unspecified, + flags: DispatchWorkItemFlags = [], + using dependencies: Dependencies, + execute work: @escaping () -> Void + ) { + guard !dependencies.forceSynchronous else { return work() } + + self.asyncAfter(deadline: deadline, qos: qos, flags: flags, execute: work) + } + + static func with(key: DispatchSpecificKey, matches context: String, using dependencies: Dependencies) -> Bool { + guard !dependencies.forceSynchronous else { return true } + + return (DispatchQueue.getSpecific(key: key) == context) + } +} diff --git a/SessionUtilitiesKit/Utilities/JSONEncoder+Utilities.swift b/SessionUtilitiesKit/Utilities/JSONEncoder+Utilities.swift new file mode 100644 index 000000000..e7511c9f8 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/JSONEncoder+Utilities.swift @@ -0,0 +1,12 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension JSONEncoder { + func with(outputFormatting: JSONEncoder.OutputFormatting) -> JSONEncoder { + let result: JSONEncoder = self + result.outputFormatting = outputFormatting + + return result + } +} diff --git a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift index 7eeb39293..3a93e06a9 100644 --- a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift +++ b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift @@ -16,7 +16,7 @@ class IdentitySpec: QuickSpec { describe("an Identity") { beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self diff --git a/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift b/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift index e17536fe7..6b98ed4f4 100644 --- a/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift +++ b/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift @@ -9,8 +9,6 @@ import Nimble @testable import SessionUtilitiesKit class PersistableRecordUtilitiesSpec: QuickSpec { - static var customWriter: DatabaseQueue! - struct TestType: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "TestType" } @@ -104,8 +102,7 @@ class PersistableRecordUtilitiesSpec: QuickSpec { describe("a PersistableRecord") { beforeEach { customWriter = try! DatabaseQueue() - PersistableRecordUtilitiesSpec.customWriter = customWriter - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: customWriter, customMigrationTargets: [ TestTarget.self diff --git a/SessionUtilitiesKitTests/General/DependenciesSpec.swift b/SessionUtilitiesKitTests/General/DependenciesSpec.swift new file mode 100644 index 000000000..60b0d0ed3 --- /dev/null +++ b/SessionUtilitiesKitTests/General/DependenciesSpec.swift @@ -0,0 +1,43 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class DependenciesSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var dependencies: Dependencies! + + describe("Dependencies") { + beforeEach { + dependencies = Dependencies() + } + + context("when accessing dateNow") { + it("creates a new date every time when not overwritten") { + let date1 = dependencies.dateNow + Thread.sleep(forTimeInterval: 0.05) + let date2 = dependencies.dateNow + + expect(date1.timeIntervalSince1970).toNot(equal(date2.timeIntervalSince1970)) + } + + it("returns the same new date every time when overwritten") { + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) + + let date1 = dependencies.dateNow + Thread.sleep(forTimeInterval: 0.05) + let date2 = dependencies.dateNow + + expect(date1.timeIntervalSince1970).to(equal(date2.timeIntervalSince1970)) + expect(date1.timeIntervalSince1970).to(equal(1234567890)) + } + } + } + } +} diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift index 1c8d502b2..fff8e4c15 100644 --- a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -50,7 +50,7 @@ class JobRunnerSpec: QuickSpec { success: @escaping (Job, Bool, Dependencies) -> (), failure: @escaping (Job, Error?, Bool, Dependencies) -> (), deferred: @escaping (Job, Dependencies) -> (), - dependencies: Dependencies + using dependencies: Dependencies ) { guard let detailsData: Data = job.details, @@ -80,14 +80,14 @@ class JobRunnerSpec: QuickSpec { } } - guard dependencies.fixedTime < details.completeTime else { return completeJob() } - - DispatchQueue.global(qos: .default).async { - while dependencies.fixedTime < details.completeTime { - Thread.sleep(forTimeInterval: 0.01) // Wait for 10ms + guard dependencies.fixedTime < details.completeTime else { + return queue.async(using: dependencies) { + completeJob() } - - queue.async { + } + + dependencies.asyncExecutions.appendTo(details.completeTime) { + queue.async(using: dependencies) { completeJob() } } @@ -103,11 +103,11 @@ class JobRunnerSpec: QuickSpec { var mockStorage: Storage! var dependencies: Dependencies! - // MARK: - JobRunner - describe("a JobRunner") { + // MARK: - Configuration + beforeEach { - mockStorage = Storage( + mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), customMigrationTargets: [ SNUtilitiesKit.self @@ -115,7 +115,8 @@ class JobRunnerSpec: QuickSpec { ) dependencies = Dependencies( storage: mockStorage, - date: Date(timeIntervalSince1970: 1234567890) + dateNow: Date(timeIntervalSince1970: 0), + forceSynchronous: true ) // Migrations add jobs which we don't want so delete them @@ -146,10 +147,10 @@ class JobRunnerSpec: QuickSpec { details: nil ) - jobRunner = JobRunner(isTestingJobRunner: true, dependencies: dependencies) + jobRunner = JobRunner(isTestingJobRunner: true, using: dependencies) jobRunner.setExecutor(TestJob.self, for: .messageSend) jobRunner.setExecutor(TestJob.self, for: .attachmentUpload) - jobRunner.setExecutor(TestJob.self, for: .attachmentDownload) + jobRunner.setExecutor(TestJob.self, for: .messageReceive) // Need to assign this to ensure it's used by nested dependencies dependencies.jobRunner = jobRunner @@ -163,9 +164,10 @@ class JobRunnerSpec: QuickSpec { mockStorage = nil dependencies = nil } - // MARK: -- when configuring + // MARK: - when configuring context("when configuring") { + // MARK: -- adds an executor correctly it("adds an executor correctly") { job1 = Job( id: 101, @@ -177,85 +179,89 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + // Save the job to the database + mockStorage.write { db in _ = try job1.inserted(db) } + expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(1)) + + // Try to start the job mockStorage.write { db in jobRunner.upsert( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + // Ensure the job isn't running, and that it has been deleted (can't retry if there + // is no executer so no failure counts) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) + // Add the executor and start the job again jobRunner.setExecutor(TestJob.self, for: .getSnodePool) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + // Job is now running + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) } } // MARK: -- when managing state - context("when managing state") { - // MARK: ---- by checking if a job is currently running - context("by checking if a job is currently running") { + // MARK: ------ returns false when not given a job it("returns false when not given a job") { expect(jobRunner.isCurrentlyRunning(nil)).to(beFalse()) } + // MARK: ------ returns false when given a job that has not been persisted it("returns false when given a job that has not been persisted") { job1 = Job(variant: .messageSend) expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ returns false when given a job that is not running it("returns false when given a job that is not running") { expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ returns true when given a non blocking job that is running it("returns true when given a non blocking job that is running") { job1 = job1.with(details: TestDetails(completeTime: 1)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) } + // MARK: ------ returns true when given a blocking job that is running it("returns true when given a blocking job that is running") { job2 = Job( id: 101, @@ -267,293 +273,314 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) - expect(jobRunner.isCurrentlyRunning(job2)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) } } // MARK: ---- by getting the details for jobs - context("by getting the details for jobs") { + // MARK: ------ returns an empty dictionary when there are no jobs it("returns an empty dictionary when there are no jobs") { expect(jobRunner.allJobInfo()).to(equal([:])) } + // MARK: ------ returns an empty dictionary when there are no jobs matching the filters it("returns an empty dictionary when there are no jobs matching the filters") { - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend)) - .toEventually( - equal([:]), - timeout: .milliseconds(50) - ) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend)).to(equal([:])) } + // MARK: ------ can filter to specific jobs it("can filter to specific jobs") { - mockStorage.write { db in - jobRunner.upsert( - db, - job: job2, - /// The `canStartJob` value needs to be `true` for the job to be added to the queue but as - /// long as `appDidFinishLaunching` hasn't been called it won't actually start running and - /// as a result we can test the "pending" state - canStartJob: true, - dependencies: dependencies - ) - } - - // Wait for there to be data and the validate the filtering works - expect(jobRunner.allJobInfo()) - .toEventuallyNot( - beEmpty(), - timeout: .milliseconds(50) - ) - expect(jobRunner.jobInfoFor(jobs: [job1])).to(equal([:])) - expect(jobRunner.jobInfoFor(jobs: [job2])) - .to(equal( - [ - 101: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: job2.details - ) - ] - )) - } - - it("can filter to running jobs") { job1 = Job( id: 100, failureCount: 0, - variant: .attachmentDownload, + variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) job2 = Job( id: 101, failureCount: 0, - variant: .attachmentDownload, + variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: nil ) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies + ) + } + + // Validate the filtering works + expect(jobRunner.allJobInfo()).toNot(beEmpty()) + expect(jobRunner.jobInfoFor(jobs: [job1])) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageReceive, + threadId: nil, + interactionId: nil, + detailsData: job1.details + ) + ])) + expect(jobRunner.jobInfoFor(jobs: [job2])) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .messageReceive, + threadId: nil, + interactionId: nil, + detailsData: nil + ) + ])) + } + + // MARK: ------ can filter to running jobs + it("can filter to running jobs") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageReceive, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageReceive, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + + jobRunner.add( db, job: job2, canStartJob: false, - dependencies: dependencies + using: dependencies ) } // Wait for there to be data and the validate the filtering works expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - equal( - [ - 100: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageReceive, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + ])) expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) } + // MARK: ------ can filter to pending jobs it("can filter to pending jobs") { job1 = Job( id: 100, failureCount: 0, - variant: .attachmentDownload, + variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) job2 = Job( id: 101, failureCount: 0, - variant: .attachmentDownload, + variant: .messageReceive, behaviour: .runOnce, shouldBlock: false, shouldSkipLaunchBecomeActive: false, nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } // Wait for there to be data and the validate the filtering works expect(jobRunner.jobInfoFor(state: .pending)) - .toEventually( - equal( - [ - 101: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .messageReceive, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + ])) expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) } + // MARK: ------ can filter to specific variants it("can filter to specific variants") { job1 = job1.with(details: TestDetails(completeTime: 1)) job2 = job2.with(details: TestDetails(completeTime: 2)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } // Wait for there to be data and the validate the filtering works expect(jobRunner.jobInfoFor(variant: .attachmentUpload)) - .toEventually( - equal( - [ - 101: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 2)) - ) - ] - ), - timeout: .milliseconds(50) - ) - expect(Array(jobRunner.allJobInfo().keys).sorted()) - .toEventually( - equal([100, 101]), - timeout: .milliseconds(50) - ) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 2)) + ) + ])) + expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) } + // MARK: ------ includes non blocking jobs it("includes non blocking jobs") { job2 = job2.with(details: TestDetails(completeTime: 1)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload)) - .toEventually( - equal( - [ - 101: - JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + ])) } + // MARK: ------ includes blocking jobs it("includes blocking jobs") { job2 = Job( id: 101, @@ -565,108 +592,139 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload)) - .toEventually( - equal( - [ - 101: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - ] - ), - timeout: .milliseconds(50) - ) + .to(equal([ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + ])) } } // MARK: ---- by checking for an existing job - context("by checking for an existing job") { + // MARK: ------ returns false for a queue that doesn't exist it("returns false for a queue that doesn't exist") { jobRunner = JobRunner( isTestingJobRunner: true, variantsToExclude: [.attachmentUpload], - dependencies: dependencies + using: dependencies ) expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails())) .to(beFalse()) } + // MARK: ------ returns false when the provided details fail to decode it("returns false when the provided details fail to decode") { expect(jobRunner.hasJob(of: .attachmentUpload, with: InvalidDetails())) .to(beFalse()) } + // MARK: ------ returns false when there is not a pending or running job it("returns false when there is not a pending or running job") { expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails())) .to(beFalse()) } + // MARK: ------ returns true when there is a pending job it("returns true when there is a pending job") { - job2 = job2.with(details: TestDetails(completeTime: 1)) + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageReceive, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageReceive, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 2)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + + jobRunner.add( db, job: job2, - /// The `canStartJob` value needs to be `true` for the job to be added to the queue but as - /// long as `appDidFinishLaunching` hasn't been called it won't actually start running and - /// as a result we can test the "pending" state canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(Array(jobRunner.jobInfoFor(state: .pending, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) - expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) + expect(Array(jobRunner.jobInfoFor(state: .pending, variant: .messageReceive).keys)) + .to(equal([101])) + expect(jobRunner.hasJob(of: .messageReceive, with: TestDetails(completeTime: 2))) .to(beTrue()) } + // MARK: ------ returns true when there is a running job it("returns true when there is a running job") { job2 = job2.with(details: TestDetails(completeTime: 1)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } + // MARK: ------ returns true when there is a blocking job it("returns true when there is a blocking job") { job2 = Job( id: 101, @@ -678,236 +736,107 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidFinishLaunching(dependencies: dependencies) + // Need to add the job before starting it since it's a 'runOnceNextLaunch' + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } + // MARK: ------ returns true when there is a non blocking job it("returns true when there is a non blocking job") { job2 = job2.with(details: TestDetails(completeTime: 1)) - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.hasJob(of: .attachmentUpload, with: TestDetails(completeTime: 1))) .to(beTrue()) } } // MARK: ---- by being notified of app launch - context("by being notified of app launch") { + // MARK: ------ does not start a job before getting the app launch call it("does not start a job before getting the app launch call") { job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ starts the job queues if there are no app launch jobs it("does nothing if there are no app launch jobs") { job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidFinishLaunching(dependencies: dependencies) - - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) - } - - it("starts the job queues after completing blocking app launch jobs") { - job1 = job1.with(details: TestDetails(completeTime: 2)) - job2 = Job( - id: 101, - failureCount: 0, - variant: .messageSend, - behaviour: .runOnceNextLaunch, - shouldBlock: true, - shouldSkipLaunchBecomeActive: false, - nextRunTimestamp: 0, - threadId: nil, - interactionId: nil, - details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - - mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( - db, - job: job1, - canStartJob: true, - dependencies: dependencies - ) - jobRunner.upsert( - db, - job: job2, - canStartJob: true, - dependencies: dependencies - ) - } - - // Not currently running - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) - expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) - - // Make sure it starts - jobRunner.appDidFinishLaunching(dependencies: dependencies) - - // Blocking job running but blocked job not - expect(jobRunner.isCurrentlyRunning(job2)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) - expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) - - // Complete 'job2' - dependencies.fixedTime = 1 - - // Blocked job eventually starts - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) - } - - it("starts the job queues alongside non blocking app launch jobs") { - job1 = job1.with(details: TestDetails(completeTime: 1)) - job2 = Job( - id: 101, - failureCount: 0, - variant: .messageSend, - behaviour: .runOnceNextLaunch, - shouldBlock: false, - shouldSkipLaunchBecomeActive: false, - nextRunTimestamp: 0, - threadId: nil, - interactionId: nil, - details: try! JSONEncoder().encode(TestDetails(completeTime: 1)) - ) - - mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( - db, - job: job1, - canStartJob: true, - dependencies: dependencies - ) - jobRunner.upsert( - db, - job: job2, - canStartJob: true, - dependencies: dependencies - ) - } - - // Not currently running - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) - - // Make sure it starts - jobRunner.appDidFinishLaunching(dependencies: dependencies) - - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) - expect(jobRunner.isCurrentlyRunning(job2)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + jobRunner.appDidFinishLaunching(using: dependencies) + expect(jobRunner.allJobInfo()).to(beEmpty()) } } // MARK: ---- by being notified of app becoming active - context("by being notified of app becoming active") { + // MARK: ------ does not start a job before getting the app active call it("does not start a job before getting the app active call") { job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ does not start the job queues if there are no app active jobs and blocking jobs are running it("does not start the job queues if there are no app active jobs and blocking jobs are running") { job1 = job1.with(details: TestDetails(completeTime: 2)) job2 = Job( @@ -920,46 +849,39 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } // Not currently running - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) // Start the blocking job - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) // Make sure the other queues don't start - jobRunner.appDidBecomeActive(dependencies: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ does not start the job queues if there are app active jobs and blocking jobs are running it("does not start the job queues if there are app active jobs and blocking jobs are running") { job1 = Job( id: 100, @@ -971,7 +893,9 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 2)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 2)) ) job2 = Job( id: 101, @@ -983,68 +907,62 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies ) } // Not currently running - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) // Start the blocking queue - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) // Make sure the other queues don't start - jobRunner.appDidBecomeActive(dependencies: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) } + // MARK: ------ starts the job queues if there are no app active jobs it("starts the job queues if there are no app active jobs") { job1 = job1.with(details: TestDetails(completeTime: 1)) + jobRunner.appDidFinishLaunching(using: dependencies) mockStorage.write { db in - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) } - jobRunner.appDidBecomeActive(dependencies: dependencies) + // Make sure it isn't already started + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + // Make sure it starts after 'appDidBecomeActive' is called + jobRunner.appDidBecomeActive(using: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) } + // MARK: ------ starts the job queues if there are app active jobs it("starts the job queues if there are app active jobs") { job1 = Job( id: 100, @@ -1056,7 +974,9 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) job2 = Job( id: 101, @@ -1068,78 +988,348 @@ class JobRunnerSpec: QuickSpec { nextRunTimestamp: 0, threadId: nil, interactionId: nil, - details: try? JSONEncoder().encode(TestDetails(completeTime: 1)) + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) ) + jobRunner.appDidFinishLaunching(using: dependencies) mockStorage.write { db in - try job1.insert(db) - try job2.insert(db) - - jobRunner.upsert( + jobRunner.add( db, job: job1, canStartJob: true, - dependencies: dependencies + using: dependencies ) - jobRunner.upsert( + jobRunner.add( db, job: job2, canStartJob: true, - dependencies: dependencies + using: dependencies + ) + } + + // Not currently running + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) + + // Make sure the queues are started + jobRunner.appDidBecomeActive(using: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) + } + + // MARK: ------ starts the job queues after completing blocking app launch jobs + it("starts the job queues after completing blocking app launch jobs") { + job1 = job1.with(details: TestDetails(completeTime: 2)) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceNextLaunch, + shouldBlock: true, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies ) } // Not currently running expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beFalse(), - timeout: .milliseconds(50) - ) + .to(beFalse()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) - // Make sure the queues are started - jobRunner.appDidBecomeActive(dependencies: dependencies) + // Make sure it starts + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) - expect(jobRunner.isCurrentlyRunning(job1)) - .toEventually( - beTrue(), - timeout: .milliseconds(50) - ) + // Blocking job running but blocked job not expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + + // Complete 'job2' + dependencies.stepForwardInTime() + + // Blocked job eventually starts + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) + } + + // MARK: ------ starts the job queues alongside non blocking app launch jobs + it("starts the job queues alongside non blocking app launch jobs") { + job1 = job1.with(details: TestDetails(completeTime: 1)) + job2 = Job( + id: 101, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnceNextLaunch, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + jobRunner.add( + db, + job: job2, + canStartJob: true, + using: dependencies + ) + } + + // Not currently running + expect(jobRunner.isCurrentlyRunning(job1)).to(beFalse()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beFalse()) + + // Make sure it starts + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + expect(jobRunner.isCurrentlyRunning(job1)).to(beTrue()) + expect(jobRunner.isCurrentlyRunning(job2)).to(beTrue()) + } + } + + // MARK: ---- by checking if a job can be added to the queue + context("by checking if a job can be added to the queue") { + // MARK: ------ does not add a general job to the queue before launch + it("does not add a general job to the queue before launch") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(jobRunner.allJobInfo()).to(beEmpty()) + } + + // MARK: ------ adds a launch job to the queue in a pending state before launch + it("adds a launch job to the queue in a pending state before launch") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnLaunch, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.jobInfoFor(state: [.pending]).keys)).to(equal([100])) + } + + // MARK: ------ does not add a general job to the queue after launch but before becoming active + it("does not add a general job to the queue after launch but before becoming active") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(jobRunner.allJobInfo()).to(beEmpty()) + } + + // MARK: ------ adds a launch job to the queue in a pending state after launch but before becoming active + it("adds a launch job to the queue in a pending state after launch but before becoming active") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnLaunch, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.jobInfoFor(state: .pending).keys)).to(equal([100])) + } + + // MARK: ------ adds a general job to the queue after becoming active + it("adds a general job to the queue after becoming active") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .runOnce, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.allJobInfo().keys)).to(equal([100])) + } + + // MARK: ------ adds a launch job to the queue and starts it after becoming active + it("adds a launch job to the queue and starts it after becoming active") { + job1 = Job( + id: 100, + failureCount: 0, + variant: .messageSend, + behaviour: .recurringOnLaunch, + shouldBlock: false, + shouldSkipLaunchBecomeActive: false, + nextRunTimestamp: 0, + threadId: nil, + interactionId: nil, + details: try? JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(completeTime: 1)) + ) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) + + mockStorage.write { db in + jobRunner.add( + db, + job: job1, + canStartJob: true, + using: dependencies + ) + } + + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) } } } // MARK: -- when running jobs - context("when running jobs") { beforeEach { - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) } // MARK: ---- by adding - context("by adding") { + // MARK: ------ does not start until after the db transaction completes it("does not start until after the db transaction completes") { job1 = job1.with(details: TestDetails(completeTime: 1)) mockStorage.write { db in - jobRunner.add(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.add(db, job: job1, canStartJob: true, using: dependencies) - // Wait for 10ms to give the job the chance to be added - Thread.sleep(forTimeInterval: 0.01) - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .to(beEmpty()) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } - // Wait for 10ms for the job to actually be added - Thread.sleep(forTimeInterval: 0.01) - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .to(equal([100])) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) } } - // MARK: ---- with dependencies - context("with dependencies") { + // MARK: ---- with job dependencies + context("with job dependencies") { + // MARK: ------ starts dependencies first it("starts dependencies first") { job1 = job1.with(details: TestDetails(completeTime: 1)) job2 = job2.with(details: TestDetails(completeTime: 2)) @@ -1149,17 +1339,15 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) } + // MARK: ------ removes the initial job from the queue it("removes the initial job from the queue") { job1 = job1.with(details: TestDetails(completeTime: 1)) job2 = job2.with(details: TestDetails(completeTime: 2)) @@ -1169,18 +1357,16 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the initial job is removed from the queue expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) } - + + // MARK: ------ starts the initial job when the dependencies succeed it("starts the initial job when the dependencies succeed") { job1 = job1.with(details: TestDetails(completeTime: 2)) job2 = job2.with(details: TestDetails(completeTime: 1)) @@ -1190,26 +1376,21 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure the initial job starts - dependencies.fixedTime = 1 + dependencies.stepForwardInTime() expect(Array(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + .to(equal([100])) } + // MARK: ------ does not start the initial job if the dependencies are deferred it("does not start the initial job if the dependencies are deferred") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) job2 = job2.with(details: TestDetails(result: .deferred, completeTime: 1)) @@ -1219,26 +1400,20 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ does not start the initial job if the dependencies fail it("does not start the initial job if the dependencies fail") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1)) @@ -1248,26 +1423,20 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ does not delete the initial job if the dependencies fail it("does not delete the initial job if the dependencies fail") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) job2 = job2.with(details: TestDetails(result: .failure, completeTime: 1)) @@ -1277,24 +1446,18 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs - dependencies.fixedTime = 1 + dependencies.stepForwardInTime() expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(beEmpty()) // Stop the queues so it doesn't run out of retry attempts jobRunner.stopAndClearPendingJobs(exceptForVariant: nil, onComplete: nil) @@ -1303,6 +1466,7 @@ class JobRunnerSpec: QuickSpec { expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(2)) } + // MARK: ------ deletes the initial job if the dependencies permanently fail it("deletes the initial job if the dependencies permanently fail") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 2)) job2 = job2.with(details: TestDetails(result: .permanentFailure, completeTime: 1)) @@ -1312,24 +1476,18 @@ class JobRunnerSpec: QuickSpec { try job2.insert(db) try JobDependencies(jobId: job1.id!, dependantId: job2.id!).insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - equal([101]), - timeout: .milliseconds(50) - ) + .to(equal([101])) expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs - dependencies.fixedTime = 1 + dependencies.stepForwardInTime() expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) @@ -1338,63 +1496,48 @@ class JobRunnerSpec: QuickSpec { } // MARK: -- when completing jobs - context("when completing jobs") { beforeEach { - jobRunner.appDidFinishLaunching(dependencies: dependencies) + jobRunner.appDidFinishLaunching(using: dependencies) + jobRunner.appDidBecomeActive(using: dependencies) } // MARK: ---- by succeeding - context("by succeeding") { + // MARK: ------ removes the job from the queue it("removes the job from the queue") { job1 = job1.with(details: TestDetails(result: .success, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ deletes the job it("deletes the job") { job1 = job1.with(details: TestDetails(result: .success, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) @@ -1402,168 +1545,121 @@ class JobRunnerSpec: QuickSpec { } // MARK: ---- by deferring - context("by deferring") { + // MARK: ------ reschedules the job to run again later it("reschedules the job to run again later") { job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) - expect(mockStorage.read { db in try Job.select(.details).asRequest(of: Data.self).fetchOne(db) }) - .to(equal(try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 3)))) + dependencies.stepForwardInTime() + expect(jobRunner.jobInfoFor(state: .running)).to(beEmpty()) + expect { + mockStorage.read { db in try Job.select(.details).asRequest(of: Data.self).fetchOne(db) } + }.to(equal( + try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .deferred, completeTime: 3)) + )) } + // MARK: ------ does not delete the job it("does not delete the job") { job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(jobRunner.jobInfoFor(state: .running)).to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0)) } + // MARK: ------ fails the job if it is deferred too many times it("fails the job if it is deferred too many times") { job1 = job1.with(details: TestDetails(result: .deferred, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure it runs - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Progress the time - dependencies.fixedTime = 2 + dependencies.stepForwardInTime() // Make sure it finishes once expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - equal( - [ - 100: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails( - result: .deferred, - completeTime: 3 - )) - ) - ] - ), - timeout: .milliseconds(50) - ) - dependencies.fixedTime = 3 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageSend, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .deferred, completeTime: 3)) + ) + ])) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Progress the time - dependencies.fixedTime = 4 + dependencies.stepForwardInTime() // Make sure it finishes twice expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - equal( - [ - 100: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails( - result: .deferred, - completeTime: 5 - )) - ) - ] - ), - timeout: .milliseconds(50) - ) - dependencies.fixedTime = 5 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageSend, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .deferred, completeTime: 5)) + ) + ])) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Progress the time - dependencies.fixedTime = 6 + dependencies.stepForwardInTime() // Make sure it's finishes the last time expect(jobRunner.jobInfoFor(state: .running)) - .toEventually( - equal( - [ - 100: JobRunner.JobInfo( - variant: .attachmentUpload, - threadId: nil, - interactionId: nil, - detailsData: try! JSONEncoder().encode(TestDetails( - result: .deferred, - completeTime: 7 - )) - ) - ] - ), - timeout: .milliseconds(50) - ) - dependencies.fixedTime = 7 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + .to(equal([ + 100: JobRunner.JobInfo( + variant: .messageSend, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder() + .with(outputFormatting: .sortedKeys) + .encode(TestDetails(result: .deferred, completeTime: 7)) + ) + ])) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Make sure the job was marked as failed expect(mockStorage.read { db in try Job.fetchOne(db, id: 100)?.failureCount }).to(equal(1)) @@ -1571,56 +1667,41 @@ class JobRunnerSpec: QuickSpec { } // MARK: ---- by failing - context("by failing") { + // MARK: ------ removes the job from the queue it("removes the job from the queue") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ does not delete the job it("does not delete the job") { job1 = job1.with(details: TestDetails(result: .failure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).toNot(equal(0)) @@ -1628,56 +1709,41 @@ class JobRunnerSpec: QuickSpec { } // MARK: ---- by permanently failing - context("by permanently failing") { + // MARK: ------ removes the job from the queue it("removes the job from the queue") { job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) } + // MARK: ------ deletes the job it("deletes the job") { job1 = job1.with(details: TestDetails(result: .permanentFailure, completeTime: 1)) mockStorage.write { db in try job1.insert(db) - jobRunner.upsert(db, job: job1, canStartJob: true, dependencies: dependencies) + jobRunner.upsert(db, job: job1, canStartJob: true, using: dependencies) } // Make sure the dependency is run - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - equal([100]), - timeout: .milliseconds(50) - ) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(equal([100])) // Make sure there are no running jobs - dependencies.fixedTime = 1 - expect(Array(jobRunner.jobInfoFor(state: .running).keys)) - .toEventually( - beEmpty(), - timeout: .milliseconds(50) - ) + dependencies.stepForwardInTime() + expect(Array(jobRunner.jobInfoFor(state: .running).keys)).to(beEmpty()) // Make sure the jobs were deleted expect(mockStorage.read { db in try Job.fetchCount(db) }).to(equal(0)) diff --git a/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift b/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift index 1030e6900..d3349a15a 100644 --- a/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift +++ b/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift @@ -89,7 +89,7 @@ class BatchResponseSpec: QuickSpec { "code": 200, "headers": { "testKey": "testValue" - }, + } } """ let subResponse: HTTP.BatchSubResponse? = try? JSONDecoder().decode( @@ -108,7 +108,7 @@ class BatchResponseSpec: QuickSpec { "code": 200, "headers": { "testKey": "testValue" - }, + } } """ let subResponse: HTTP.BatchSubResponse? = try? JSONDecoder().decode( @@ -149,7 +149,7 @@ class BatchResponseSpec: QuickSpec { testType2 = TestType2(intValue: 123, stringValue2: "test2") data = """ [\([ - try! JSONEncoder().encode( + try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( HTTP.BatchSubResponse( code: 200, headers: [:], @@ -157,7 +157,7 @@ class BatchResponseSpec: QuickSpec { failedToParseBody: false ) ), - try! JSONEncoder().encode( + try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( HTTP.BatchSubResponse( code: 200, headers: [:], @@ -200,8 +200,7 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } it("fails if the data is not JSON") { @@ -213,8 +212,7 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } it("fails if the data is not a JSON array") { @@ -226,8 +224,7 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } it("fails if the JSON array does not have the same number of items as the expected types") { @@ -243,14 +240,13 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } it("fails if one of the JSON array values fails to decode") { data = """ [\([ - try! JSONEncoder().encode( + try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( HTTP.BatchSubResponse( code: 200, headers: [:], @@ -274,8 +270,7 @@ class BatchResponseSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkUntilComplete() - expect(error?.localizedDescription) - .to(equal(HTTPError.parsingFailed.localizedDescription)) + expect(error).to(matchError(HTTPError.parsingFailed)) } } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index a8c34aec9..39f6c73f5 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -15,7 +15,8 @@ public protocol AttachmentApprovalViewControllerDelegate: AnyObject { _ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, - messageText: String? + messageText: String?, + using dependencies: Dependencies ) func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) @@ -664,7 +665,7 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {} - func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) { + func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar, using dependencies: Dependencies) { // Toolbar flickers in and out if there are errors // and remains visible momentarily after share extension is dismissed. // It's easiest to just hide it at this point since we're done with it. @@ -672,7 +673,7 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { attachmentTextToolbar.isUserInteractionEnabled = false attachmentTextToolbar.isHidden = true - approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: attachmentTextToolbar.messageText) + approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: attachmentTextToolbar.messageText, using: dependencies) } func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 53a29da81..79ac67d63 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -10,7 +10,7 @@ import PureLayout let kMaxMessageBodyCharacterCount = 2000 protocol AttachmentTextToolbarDelegate: AnyObject { - func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) + func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar, using dependencies: Dependencies) func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) @@ -210,9 +210,11 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { } // MARK: - Actions + + @objc func didTapSend() { onSend() } - @objc func didTapSend() { - attachmentTextToolbarDelegate?.attachmentTextToolbarDidTapSend(self) + private func onSend(using dependencies: Dependencies = Dependencies()) { + attachmentTextToolbarDelegate?.attachmentTextToolbarDidTapSend(self, using: dependencies) } // MARK: - UITextViewDelegate diff --git a/_SharedTestUtilities/CommonMockedExtensions.swift b/_SharedTestUtilities/CommonMockedExtensions.swift index 06876f038..853dc44f3 100644 --- a/_SharedTestUtilities/CommonMockedExtensions.swift +++ b/_SharedTestUtilities/CommonMockedExtensions.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import Sodium import Curve25519Kit @@ -37,3 +38,40 @@ extension Job: Mocked { extension Job.Variant: Mocked { static var mockValue: Job.Variant = .messageSend } + +extension Network.RequestType: MockedGeneric { + typealias Generic = T + + static func mockValue(type: T.Type) -> Network.RequestType { + return Network.RequestType(id: "mock") { Fail(error: MockError.mockedData).eraseToAnyPublisher() } + } +} + +extension AnyPublisher: MockedGeneric where Failure == Error { + typealias Generic = Output + + static func mockValue(type: Output.Type) -> AnyPublisher { + return Fail(error: MockError.mockedData).eraseToAnyPublisher() + } +} + +extension Array: MockedGeneric { + typealias Generic = Element + + static func mockValue(type: Element.Type) -> [Element] { return [] } +} + +extension Dictionary: MockedDoubleGeneric { + typealias GenericA = Key + typealias GenericB = Value + + static func mockValue(typeA: Key.Type, typeB: Value.Type) -> [Key: Value] { return [:] } +} + +extension URLRequest: Mocked { + static var mockValue: URLRequest = URLRequest(url: URL(fileURLWithPath: "mock")) +} + +extension NoResponse: Mocked { + static var mockValue: NoResponse = NoResponse() +} diff --git a/_SharedTestUtilities/Mock.swift b/_SharedTestUtilities/Mock.swift index 74e92d38a..aed05ca40 100644 --- a/_SharedTestUtilities/Mock.swift +++ b/_SharedTestUtilities/Mock.swift @@ -3,21 +3,11 @@ import Foundation import SessionUtilitiesKit -// MARK: - Mocked +// MARK: - MockError -protocol Mocked { static var mockValue: Self { get } } - -func any() -> R { R.mockValue } -func any() -> R { unsafeBitCast(0, to: R.self) } -func any() -> [K: V] { [:] } -func any() -> Float { 0 } -func any() -> Double { 0 } -func any() -> String { "" } -func any() -> Data { Data() } - -func anyAny() -> Any { 0 } // Unique name for compilation performance reasons -func anyArray() -> [R] { [] } // Unique name for compilation performance reasons -func anySet() -> Set { Set() } // Unique name for compilation performance reasons +public enum MockError: Error { + case mockedData +} // MARK: - Mock @@ -39,7 +29,12 @@ public class Mock { } @discardableResult internal func accept(funcName: String = #function, checkArgs: [Any?], actionArgs: [Any?]) -> Any? { - return functionHandler.accept(funcName, parameterSummary: summary(for: checkArgs), actionArgs: actionArgs) + return functionHandler.accept( + funcName, + parameterCount: checkArgs.count, + parameterSummary: summary(for: checkArgs), + actionArgs: actionArgs + ) } // MARK: - Functions @@ -73,6 +68,8 @@ public class Mock { .sorted() return "[\(sortedValues.joined(separator: ", "))]" + case let data as Data: return "Data(base64Encoded: \(data.base64EncodedString()))" + default: return String(reflecting: argument) // Default to the `debugDescription` if available } } @@ -81,19 +78,21 @@ public class Mock { // MARK: - MockFunctionHandler protocol MockFunctionHandler { - func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? + func accept(_ functionName: String, parameterCount: Int, parameterSummary: String, actionArgs: [Any?]) -> Any? } // MARK: - MockFunction internal class MockFunction { var name: String + var parameterCount: Int var parameterSummary: String var actions: [([Any?]) -> Void] var returnValue: Any? - init(name: String, parameterSummary: String, actions: [([Any?]) -> Void], returnValue: Any?) { + init(name: String, parameterCount: Int, parameterSummary: String, actions: [([Any?]) -> Void], returnValue: Any?) { self.name = name + self.parameterCount = parameterCount self.parameterSummary = parameterSummary self.actions = actions self.returnValue = returnValue @@ -106,10 +105,11 @@ internal class MockFunctionBuilder: MockFunctionHandler { private let callBlock: (inout T) throws -> R private let mockInit: (MockFunctionHandler?) -> Mock private var functionName: String? + private var parameterCount: Int? private var parameterSummary: String? private var actions: [([Any?]) -> Void] = [] private var returnValue: R? - internal var returnValueGenerator: ((String, String) -> R?)? + internal var returnValueGenerator: ((String, Int, String) -> R?)? // MARK: - Initialization @@ -131,53 +131,63 @@ internal class MockFunctionBuilder: MockFunctionHandler { // MARK: - MockFunctionHandler - func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? { + func accept(_ functionName: String, parameterCount: Int, parameterSummary: String, actionArgs: [Any?]) -> Any? { self.functionName = functionName + self.parameterCount = parameterCount self.parameterSummary = parameterSummary - return (returnValue ?? returnValueGenerator?(functionName, parameterSummary)) + return (returnValue ?? returnValueGenerator?(functionName, parameterCount, parameterSummary)) } // MARK: - Build func build() throws -> MockFunction { var completionMock = mockInit(self) as! T - _ = try callBlock(&completionMock) + _ = try? callBlock(&completionMock) - guard let name: String = functionName, let parameterSummary: String = parameterSummary else { + guard let name: String = functionName, let parameterCount: Int = parameterCount, let parameterSummary: String = parameterSummary else { preconditionFailure("Attempted to build the MockFunction before it was called") } - return MockFunction(name: name, parameterSummary: parameterSummary, actions: actions, returnValue: returnValue) + return MockFunction(name: name, parameterCount: parameterCount, parameterSummary: parameterSummary, actions: actions, returnValue: returnValue) } } // MARK: - FunctionConsumer internal class FunctionConsumer: MockFunctionHandler { + struct Key: Equatable, Hashable { + let name: String + let paramCount: Int + } + var trackCalls: Bool = true var functionBuilders: [() throws -> MockFunction?] = [] - var functionHandlers: [String: [String: MockFunction]] = [:] - var calls: Atomic<[String: [String]]> = Atomic([:]) + var functionHandlers: [Key: [String: MockFunction]] = [:] + var calls: Atomic<[Key: [String]]> = Atomic([:]) - func accept(_ functionName: String, parameterSummary: String, actionArgs: [Any?]) -> Any? { + func accept(_ functionName: String, parameterCount: Int, parameterSummary: String, actionArgs: [Any?]) -> Any? { + let key: Key = Key(name: functionName, paramCount: parameterCount) + if !functionBuilders.isEmpty { functionBuilders .compactMap { try? $0() } .forEach { function in - functionHandlers[function.name] = (functionHandlers[function.name] ?? [:]) + let key: Key = Key(name: function.name, paramCount: function.parameterCount) + + functionHandlers[key] = (functionHandlers[key] ?? [:]) .setting(function.parameterSummary, function) } functionBuilders.removeAll() } - guard let expectation: MockFunction = firstFunction(for: functionName, matchingParameterSummaryIfPossible: parameterSummary) else { + guard let expectation: MockFunction = firstFunction(for: key, matchingParameterSummaryIfPossible: parameterSummary) else { preconditionFailure("No expectations found for \(functionName)") } // Record the call so it can be validated later (assuming we are tracking calls) if trackCalls { - calls.mutate { $0[functionName] = ($0[functionName] ?? []).appending(parameterSummary) } + calls.mutate { $0[key] = ($0[key] ?? []).appending(parameterSummary) } } for action in expectation.actions { @@ -187,8 +197,8 @@ internal class FunctionConsumer: MockFunctionHandler { return expectation.returnValue } - func firstFunction(for name: String, matchingParameterSummaryIfPossible parameterSummary: String) -> MockFunction? { - guard let possibleExpectations: [String: MockFunction] = functionHandlers[name] else { return nil } + func firstFunction(for key: Key, matchingParameterSummaryIfPossible parameterSummary: String) -> MockFunction? { + guard let possibleExpectations: [String: MockFunction] = functionHandlers[key] else { return nil } guard let expectation: MockFunction = possibleExpectations[parameterSummary] else { // A `nil` response might be value but in a lot of places we will need to force-cast diff --git a/_SharedTestUtilities/MockCaches.swift b/_SharedTestUtilities/MockCaches.swift new file mode 100644 index 000000000..493f2c579 --- /dev/null +++ b/_SharedTestUtilities/MockCaches.swift @@ -0,0 +1,51 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +class MockCaches: CachesType { + private var cacheInstances: [Int: MutableCacheType] = [:] + + // MARK: - Immutable Access + + public subscript(cache: CacheInfo.Config) -> I { + get { MockCaches.getValueSettingIfNull(cache: cache, &cacheInstances) } + } + + public subscript(cache: CacheInfo.Config) -> M? { + get { return (cacheInstances[cache.key] as? M) } + set { cacheInstances[cache.key] = newValue.map { cache.mutableInstance($0) } } + } + + // MARK: - Mutable Access + + @discardableResult public func mutate( + cache: CacheInfo.Config, + _ mutation: (inout M) -> R + ) -> R { + var value: M = ((cacheInstances[cache.key] as? M) ?? cache.createInstance()) + return mutation(&value) + } + + @discardableResult public func mutate( + cache: CacheInfo.Config, + _ mutation: (inout M) throws -> R + ) throws -> R { + var value: M = ((cacheInstances[cache.key] as? M) ?? cache.createInstance()) + return try mutation(&value) + } + + @discardableResult private static func getValueSettingIfNull( + cache: CacheInfo.Config, + _ store: inout [Int: MutableCacheType] + ) -> I { + guard let value: M = (store[cache.key] as? M) else { + let value: M = cache.createInstance() + let mutableInstance: MutableCacheType = cache.mutableInstance(value) + store[cache.key] = mutableInstance + return cache.immutableInstance(value) + } + + return cache.immutableInstance(value) + } +} diff --git a/_SharedTestUtilities/MockCrypto.swift b/_SharedTestUtilities/MockCrypto.swift new file mode 100644 index 000000000..15f7efbcd --- /dev/null +++ b/_SharedTestUtilities/MockCrypto.swift @@ -0,0 +1,24 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +class MockCrypto: Mock, CryptoType { + func size(_ size: Crypto.Size) -> Int { + return accept(funcName: "size(\(size.id))", args: size.args) as! Int + } + + func perform(_ action: Crypto.Action) throws -> Array { + return try accept(funcName: "perform(\(action.id))", args: action.args) as? Array ?? { + throw CryptoError.failedToGenerateOutput + }() + } + + func verify(_ verification: Crypto.Verification) -> Bool { + return accept(funcName: "verify(\(verification.id))", args: verification.args) as! Bool + } + + func generate(_ keyPairType: Crypto.KeyPairType) -> KeyPair? { + return accept(funcName: "generate(\(keyPairType.id))", args: keyPairType.args) as? KeyPair + } +} diff --git a/_SharedTestUtilities/MockGeneralCache.swift b/_SharedTestUtilities/MockGeneralCache.swift index fe19b7a0f..554c325cd 100644 --- a/_SharedTestUtilities/MockGeneralCache.swift +++ b/_SharedTestUtilities/MockGeneralCache.swift @@ -3,7 +3,7 @@ import Foundation import SessionUtilitiesKit -class MockGeneralCache: Mock, MutableGeneralCacheType { +class MockGeneralCache: Mock, GeneralCacheType { var encodedPublicKey: String? { get { return accept() as? String } set { accept(args: [newValue]) } diff --git a/_SharedTestUtilities/MockJobRunner.swift b/_SharedTestUtilities/MockJobRunner.swift index 6f34caff6..bbf6a87c3 100644 --- a/_SharedTestUtilities/MockJobRunner.swift +++ b/_SharedTestUtilities/MockJobRunner.swift @@ -17,15 +17,19 @@ class MockJobRunner: Mock, JobRunnerType { return accept(args: [queue]) as! Bool } + func afterBlockingQueue(callback: @escaping () -> ()) { + callback() + } + // MARK: - State Management func jobInfoFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: JobRunner.JobInfo] { return accept(args: [jobs, state, variant]) as! [Int64: JobRunner.JobInfo] } - func appDidFinishLaunching(dependencies: Dependencies) {} - func appDidBecomeActive(dependencies: Dependencies) {} - func startNonBlockingQueues(dependencies: Dependencies) {} + func appDidFinishLaunching(using dependencies: Dependencies) {} + func appDidBecomeActive(using dependencies: Dependencies) {} + func startNonBlockingQueues(using dependencies: Dependencies) {} func stopAndClearPendingJobs(exceptForVariant: Job.Variant?, onComplete: (() -> ())?) { accept(args: [exceptForVariant, onComplete]) @@ -34,15 +38,15 @@ class MockJobRunner: Mock, JobRunnerType { // MARK: - Job Scheduling - @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) -> Job? { + @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) -> Job? { return accept(args: [db, job, canStartJob]) as? Job } - func upsert(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) { + func upsert(_ db: Database, job: Job?, canStartJob: Bool, using dependencies: Dependencies) { accept(args: [db, job, canStartJob]) } - func insert(_ db: Database, job: Job?, before otherJob: Job, dependencies: Dependencies) -> (Int64, Job)? { + func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, Job)? { return accept(args: [db, job, otherJob]) as? (Int64, Job) } } diff --git a/_SharedTestUtilities/MockNetwork.swift b/_SharedTestUtilities/MockNetwork.swift new file mode 100644 index 000000000..e067d0971 --- /dev/null +++ b/_SharedTestUtilities/MockNetwork.swift @@ -0,0 +1,114 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import SessionUtilitiesKit + +// MARK: - MockNetwork + +class MockNetwork: Mock, NetworkType { + var requestData: RequestData? + + func send(_ request: Network.RequestType) -> AnyPublisher<(ResponseInfoType, T), Error> { + requestData = request.data + + return accept(funcName: "send(\(request.id))", args: request.args) as! AnyPublisher<(ResponseInfoType, T), Error> + } + + static func response(info: MockResponseInfo = .mockValue, with value: T) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return Just((info, try? JSONEncoder().with(outputFormatting: .sortedKeys).encode(value))) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + static func response(info: MockResponseInfo = .mockValue, type: T.Type) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return response(info: info, with: T.mockValue) + } + + static func response(info: MockResponseInfo = .mockValue, type: Array.Type) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return response(info: info, with: [T.mockValue]) + } + + static func batchResponseData( + info: MockResponseInfo = .mockValue, + with value: [(endpoint: E, data: Data)] + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + let data: Data = "[\(value.map { String(data: $0.data, encoding: .utf8)! }.joined(separator: ","))]" + .data(using: .utf8)! + + return Just((info, data)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + static func response(info: MockResponseInfo = .mockValue, data: Data) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return Just((info, data)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + static func nullResponse(info: MockResponseInfo = .mockValue) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return Just((info, nil)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } +} + +// MARK: - MockResponseInfo + +struct MockResponseInfo: ResponseInfoType, Mocked { + static let mockValue: MockResponseInfo = MockResponseInfo(requestData: .fallbackData, code: 200, headers: [:]) + + let requestData: RequestData + let code: Int + let headers: [String: String] + + init(requestData: RequestData, code: Int, headers: [String: String]) { + self.requestData = requestData + self.code = code + self.headers = headers + } +} + +struct RequestData: Codable { + static let fallbackData: RequestData = RequestData(urlString: nil, httpMethod: "GET", headers: [:], body: nil) + + let urlString: String? + let httpMethod: String + let headers: [String: String] + let body: Data? +} + +extension Network.RequestType { + var data: RequestData { + return RequestData( + urlString: url, + httpMethod: (method ?? ""), + headers: (headers ?? [:]), + body: body + ) + } +} + +// MARK: - HTTP.BatchSubResponse Encoding Convenience + +extension Encodable where Self: Codable { + func batchSubResponse() -> Data { + return try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( + HTTP.BatchSubResponse( + code: 200, + headers: [:], + body: self, + failedToParseBody: false + ) + ) + } +} + +extension Mocked where Self: Codable { + static func mockBatchSubResponse() -> Data { return mockValue.batchSubResponse() } +} + +extension Array where Element: Mocked, Element: Codable { + static func mockBatchSubResponse() -> Data { return [Element.mockValue].batchSubResponse() } +} diff --git a/_SharedTestUtilities/Mocked.swift b/_SharedTestUtilities/Mocked.swift new file mode 100644 index 000000000..e5e9e9cd2 --- /dev/null +++ b/_SharedTestUtilities/Mocked.swift @@ -0,0 +1,78 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +// MARK: - Mocked + +protocol Mocked { static var mockValue: Self { get } } +protocol MockedGeneric { + associatedtype Generic + + static func mockValue(type: Generic.Type) -> Self +} +protocol MockedDoubleGeneric { + associatedtype GenericA + associatedtype GenericB + + static func mockValue(typeA: GenericA.Type, typeB: GenericB.Type) -> Self +} + +// MARK: - DSL + +func any() -> R { R.mockValue } +func any(type: R.Generic.Type) -> R { R.mockValue(type: type) } +func any(typeA: R.GenericA.Type, typeB: R.GenericB.Type) -> R { + R.mockValue(typeA: typeA, typeB: typeB) +} +func any() -> R { unsafeBitCast(0, to: R.self) } +func any() -> [K: V] { [:] } +func any() -> Float { 0 } +func any() -> Double { 0 } +func any() -> String { "" } +func any() -> Data { Data() } +func any() -> Bool { false } + +func anyAny() -> Any { 0 } // Unique name for compilation performance reasons +func anyArray() -> [R] { [] } // Unique name for compilation performance reasons +func anySet() -> Set { Set() } // Unique name for compilation performance reasons + +// MARK: - Extensions + +extension HTTP.BatchSubResponse: MockedGeneric where T: Mocked { + typealias Generic = T + + static func mockValue(type: Generic.Type) -> HTTP.BatchSubResponse { + return HTTP.BatchSubResponse( + code: 200, + headers: [:], + body: Generic.mockValue, + failedToParseBody: false + ) + } +} + +extension HTTP.BatchSubResponse { + static func mockArrayValue(type: M.Type) -> HTTP.BatchSubResponse> { + return HTTP.BatchSubResponse( + code: 200, + headers: [:], + body: [M.mockValue], + failedToParseBody: false + ) + } +} + +// MARK: - Encodable Convenience + +extension Mocked where Self: Encodable { + func encoded() -> Data { try! JSONEncoder().with(outputFormatting: .sortedKeys).encode(self) } +} + +extension MockedGeneric where Self: Encodable { + func encoded() -> Data { try! JSONEncoder().with(outputFormatting: .sortedKeys).encode(self) } +} + +extension Array where Element: Encodable { + func encoded() -> Data { try! JSONEncoder().with(outputFormatting: .sortedKeys).encode(self) } +} diff --git a/_SharedTestUtilities/NimbleExtensions.swift b/_SharedTestUtilities/NimbleExtensions.swift index d4f820ec9..12b5d740d 100644 --- a/_SharedTestUtilities/NimbleExtensions.swift +++ b/_SharedTestUtilities/NimbleExtensions.swift @@ -69,7 +69,7 @@ public func call( let actualMessage: String if callInfo.caughtException != nil { - actualMessage = "a thrown assertion (might not have been called or has no mocked return value)" + actualMessage = "a thrown assertion (invalid mock param, not called or no mocked return value)" } else if callInfo.function == nil { actualMessage = "no call details" @@ -78,7 +78,9 @@ public func call( actualMessage = "no calls" } else if !exclusiveCallsValid { - let otherFunctionsCalled: [String] = callInfo.allFunctionsCalled.filter { $0 != callInfo.functionName } + let otherFunctionsCalled: [String] = callInfo.allFunctionsCalled + .map { "\($0.name) (params: \($0.paramCount))" } + .filter { $0 != "\(callInfo.functionName) (params: \(callInfo.parameterCount))" } actualMessage = "calls to other functions: [\(otherFunctionsCalled.joined(separator: ", "))]" } @@ -132,10 +134,11 @@ fileprivate struct CallInfo { let didError: Bool let caughtException: BadInstructionException? let function: MockFunction? - let allFunctionsCalled: [String] + let allFunctionsCalled: [FunctionConsumer.Key] let desiredFunctionCalls: [String] var functionName: String { "\((function?.name).map { "\($0)" } ?? "a function")" } + var parameterCount: Int { (function?.parameterCount ?? 0) } var desiredParameters: String? { function?.parameterSummary } static var error: CallInfo { @@ -152,7 +155,7 @@ fileprivate struct CallInfo { didError: Bool = false, caughtException: BadInstructionException?, function: MockFunction?, - allFunctionsCalled: [String], + allFunctionsCalled: [FunctionConsumer.Key], desiredFunctionCalls: [String] ) { self.didError = didError @@ -169,13 +172,16 @@ fileprivate struct CallInfo { fileprivate func generateCallInfo(_ actualExpression: Expression, _ functionBlock: @escaping (inout T) throws -> R) -> CallInfo where M: Mock { var maybeFunction: MockFunction? - var allFunctionsCalled: [String] = [] + var allFunctionsCalled: [FunctionConsumer.Key] = [] var desiredFunctionCalls: [String] = [] let builderCreator: ((M) -> MockFunctionBuilder) = { validInstance in let builder: MockFunctionBuilder = MockFunctionBuilder(functionBlock, mockInit: type(of: validInstance).init) - builder.returnValueGenerator = { name, parameterSummary in + builder.returnValueGenerator = { name, parameterCount, parameterSummary in validInstance.functionConsumer - .firstFunction(for: name, matchingParameterSummaryIfPossible: parameterSummary)? + .firstFunction( + for: FunctionConsumer.Key(name: name, paramCount: parameterCount), + matchingParameterSummaryIfPossible: parameterSummary + )? .returnValue as? R } @@ -200,8 +206,13 @@ fileprivate func generateCallInfo(_ actualExpression: Expression, _ let builder: MockFunctionBuilder = builderCreator(validInstance) validInstance.functionConsumer.trackCalls = false maybeFunction = try? builder.build() + + let key: FunctionConsumer.Key = FunctionConsumer.Key( + name: (maybeFunction?.name ?? ""), + paramCount: (maybeFunction?.parameterCount ?? 0) + ) desiredFunctionCalls = validInstance.functionConsumer.calls - .wrappedValue[maybeFunction?.name ?? ""] + .wrappedValue[key] .defaulting(to: []) validInstance.functionConsumer.trackCalls = true } diff --git a/_SharedTestUtilities/SynchronousStorage.swift b/_SharedTestUtilities/SynchronousStorage.swift index 0e9e87655..86471f988 100644 --- a/_SharedTestUtilities/SynchronousStorage.swift +++ b/_SharedTestUtilities/SynchronousStorage.swift @@ -2,13 +2,60 @@ import Combine import GRDB -import SessionUtilitiesKit + +@testable import SessionUtilitiesKit class SynchronousStorage: Storage { + @discardableResult override func write( + fileName: String = #file, + functionName: String = #function, + lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), + updates: @escaping (Database) throws -> T? + ) -> T? { + guard isValid, let dbWriter: DatabaseWriter = testDbWriter else { return nil } + + // If 'forceSynchronous' is true then it's likely that we will access the database in + // a reentrant way, the 'unsafeReentrant...' functions allow us to interact with the + // database without worrying about reentrant access during tests because we can be + // confident that the tests are running on the correct thread + guard !dependencies.forceSynchronous else { + return try? dbWriter.unsafeReentrantWrite(updates) + } + + return super.write( + fileName: fileName, + functionName: functionName, + lineNumber: lineNumber, + using: dependencies, + updates: updates + ) + } + + @discardableResult override func read( + using dependencies: Dependencies = Dependencies(), + _ value: (Database) throws -> T? + ) -> T? { + guard isValid, let dbWriter: DatabaseWriter = testDbWriter else { return nil } + + // If 'forceSynchronous' is true then it's likely that we will access the database in + // a reentrant way, the 'unsafeReentrant...' functions allow us to interact with the + // database without worrying about reentrant access during tests because we can be + // confident that the tests are running on the correct thread + guard !dependencies.forceSynchronous else { + return try? dbWriter.unsafeReentrantRead(value) + } + + return super.read(using: dependencies, value) + } + + // MARK: - Async Methods + override func readPublisher( + using dependencies: Dependencies = Dependencies(), value: @escaping (Database) throws -> T ) -> AnyPublisher { - guard let result: T = super.read(value) else { + guard let result: T = self.read(using: dependencies, value) else { return Fail(error: StorageError.generic) .eraseToAnyPublisher() } @@ -18,13 +65,31 @@ class SynchronousStorage: Storage { .eraseToAnyPublisher() } + override func writeAsync( + fileName: String = #file, + functionName: String = #function, + lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), + updates: @escaping (Database) throws -> T, + completion: @escaping (Database, Result) throws -> Void + ) { + do { + let result: T = try write(using: dependencies, updates: updates) ?? { throw StorageError.failedToSave }() + write { db in try completion(db, Result.success(result)) } + } + catch { + write { db in try completion(db, Result.failure(error)) } + } + } + override func writePublisher( fileName: String = #file, functionName: String = #function, lineNumber: Int = #line, + using dependencies: Dependencies = Dependencies(), updates: @escaping (Database) throws -> T ) -> AnyPublisher { - guard let result: T = super.write(fileName: fileName, functionName: functionName, lineNumber: lineNumber, updates: updates) else { + guard let result: T = super.write(fileName: fileName, functionName: functionName, lineNumber: lineNumber, using: dependencies, updates: updates) else { return Fail(error: StorageError.generic) .eraseToAnyPublisher() } diff --git a/_SharedTestUtilities/TestExtensions.swift b/_SharedTestUtilities/TestExtensions.swift new file mode 100644 index 000000000..3086f6833 --- /dev/null +++ b/_SharedTestUtilities/TestExtensions.swift @@ -0,0 +1,9 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Collection { + subscript(test index: Index) -> Element? { + return (indices.contains(index) ? self[index] : nil) + } +} From c29827356cdf3a879db34f7bb8e8e859228966b3 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 14:43:41 +1000 Subject: [PATCH 15/50] Attempt to fix the cocoapods_cache lock logic to handle failures --- .drone.jsonnet | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.drone.jsonnet b/.drone.jsonnet index 29f24c2b5..44b2e6953 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -20,17 +20,24 @@ local install_cocoapods = { local load_cocoapods_cache = { name: 'Load CocoaPods Cache', commands: [ + ||| + if [[ ! -f /Users/drone/.cocoapods_cache.valid ]]; then + rm /Users/drone/.cocoapods_cache.lock + fi + |||, ||| while test -e /Users/drone/.cocoapods_cache.lock; do sleep 1 done |||, 'touch /Users/drone/.cocoapods_cache.lock', + 'rm /Users/drone/.cocoapods_cache.valid', ||| if [[ -d /Users/drone/.cocoapods_cache ]]; then cp -r /Users/drone/.cocoapods_cache ./Pods fi |||, + 'touch /Users/drone/.cocoapods_cache.valid', 'rm /Users/drone/.cocoapods_cache.lock' ] }; @@ -45,12 +52,14 @@ local update_cocoapods_cache = { done |||, 'touch /Users/drone/.cocoapods_cache.lock', + 'rm /Users/drone/.cocoapods_cache.valid', ||| if [[ -d ./Pods ]]; then rm -rf /Users/drone/.cocoapods_cache cp -r ./Pods /Users/drone/.cocoapods_cache fi |||, + 'touch /Users/drone/.cocoapods_cache.valid', 'rm /Users/drone/.cocoapods_cache.lock' ] }; From 15104da58e12c31481bba30dbf2d79a68669cdb4 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 14:48:02 +1000 Subject: [PATCH 16/50] More CI tweaks --- .drone.jsonnet | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 44b2e6953..5cc253a59 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -22,7 +22,7 @@ local load_cocoapods_cache = { commands: [ ||| if [[ ! -f /Users/drone/.cocoapods_cache.valid ]]; then - rm /Users/drone/.cocoapods_cache.lock + rm -f /Users/drone/.cocoapods_cache.lock fi |||, ||| @@ -31,14 +31,14 @@ local load_cocoapods_cache = { done |||, 'touch /Users/drone/.cocoapods_cache.lock', - 'rm /Users/drone/.cocoapods_cache.valid', + 'rm -f /Users/drone/.cocoapods_cache.valid', ||| if [[ -d /Users/drone/.cocoapods_cache ]]; then cp -r /Users/drone/.cocoapods_cache ./Pods fi |||, 'touch /Users/drone/.cocoapods_cache.valid', - 'rm /Users/drone/.cocoapods_cache.lock' + 'rm -f /Users/drone/.cocoapods_cache.lock' ] }; @@ -46,13 +46,18 @@ local load_cocoapods_cache = { local update_cocoapods_cache = { name: 'Update CocoaPods Cache', commands: [ + ||| + if [[ ! -f /Users/drone/.cocoapods_cache.valid ]]; then + rm -f /Users/drone/.cocoapods_cache.lock + fi + |||, ||| while test -e /Users/drone/.cocoapods_cache.lock; do sleep 1 done |||, 'touch /Users/drone/.cocoapods_cache.lock', - 'rm /Users/drone/.cocoapods_cache.valid', + 'rm -f /Users/drone/.cocoapods_cache.valid', ||| if [[ -d ./Pods ]]; then rm -rf /Users/drone/.cocoapods_cache @@ -60,7 +65,7 @@ local update_cocoapods_cache = { fi |||, 'touch /Users/drone/.cocoapods_cache.valid', - 'rm /Users/drone/.cocoapods_cache.lock' + 'rm -f /Users/drone/.cocoapods_cache.lock' ] }; From 00aef6ca9734dd4aaa3516dc34ec4230f55e2d3b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 15:01:16 +1000 Subject: [PATCH 17/50] Attempt to handle an invalid pod manifest file --- .drone.jsonnet | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 5cc253a59..4476809f6 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -13,7 +13,9 @@ local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https:/ // 'LANG' env var so we need to work around the with https://github.com/CocoaPods/CocoaPods/issues/6333 local install_cocoapods = { name: 'Install CocoaPods', - commands: ['LANG=en_US.UTF-8 pod install'] + commands: [' + LANG=en_US.UTF-8 pod install || rm -rf ./Pods && LANG=en_US.UTF-8 pod install + '] }; // Load from the cached CocoaPods directory (to speed up the build) From 2341fbf59f01ca63516881cf9b6497732ae19480 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 15:27:40 +1000 Subject: [PATCH 18/50] Further tweaks to CI scripts --- .drone.jsonnet | 28 +++++++++++++--------------- Scripts/drone-static-upload.sh | 5 +---- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 4476809f6..aa6695992 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -23,23 +23,22 @@ local load_cocoapods_cache = { name: 'Load CocoaPods Cache', commands: [ ||| - if [[ ! -f /Users/drone/.cocoapods_cache.valid ]]; then - rm -f /Users/drone/.cocoapods_cache.lock - fi - |||, - ||| + LOOP_BREAK=0 while test -e /Users/drone/.cocoapods_cache.lock; do sleep 1 + LOOP_BREAK=$((LOOP_BREAK + 1)) + + if [[ LOOP_BREAK >= 600 ]]; then + 'rm -f /Users/drone/.cocoapods_cache.lock' + fi done |||, 'touch /Users/drone/.cocoapods_cache.lock', - 'rm -f /Users/drone/.cocoapods_cache.valid', ||| if [[ -d /Users/drone/.cocoapods_cache ]]; then cp -r /Users/drone/.cocoapods_cache ./Pods fi |||, - 'touch /Users/drone/.cocoapods_cache.valid', 'rm -f /Users/drone/.cocoapods_cache.lock' ] }; @@ -49,24 +48,23 @@ local update_cocoapods_cache = { name: 'Update CocoaPods Cache', commands: [ ||| - if [[ ! -f /Users/drone/.cocoapods_cache.valid ]]; then - rm -f /Users/drone/.cocoapods_cache.lock - fi - |||, - ||| + LOOP_BREAK=0 while test -e /Users/drone/.cocoapods_cache.lock; do sleep 1 + LOOP_BREAK=$((LOOP_BREAK + 1)) + + if [[ LOOP_BREAK >= 600 ]]; then + 'rm -f /Users/drone/.cocoapods_cache.lock' + fi done |||, 'touch /Users/drone/.cocoapods_cache.lock', - 'rm -f /Users/drone/.cocoapods_cache.valid', ||| if [[ -d ./Pods ]]; then rm -rf /Users/drone/.cocoapods_cache cp -r ./Pods /Users/drone/.cocoapods_cache fi |||, - 'touch /Users/drone/.cocoapods_cache.valid', 'rm -f /Users/drone/.cocoapods_cache.lock' ] }; @@ -134,7 +132,7 @@ local update_cocoapods_cache = { name: 'Build', commands: [ 'mkdir build', - 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -configuration "App Store Release" -sdk iphoneos -archivePath ./build/Session.xcarchive -destination "generic/platform=iOS" -allowProvisioningUpdates CODE_SIGNING_ALLOWED=NO' + 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -configuration "App Store Release" -sdk iphoneos -archivePath ./build/Session.xcarchive -destination "generic/platform=iOS" -allowProvisioningUpdates CODE_SIGNING_ALLOWED=NO | ./Pods/xcbeautify/xcbeautify --is-ci' ], }, update_cocoapods_cache, diff --git a/Scripts/drone-static-upload.sh b/Scripts/drone-static-upload.sh index 60906b619..260f8f9af 100755 --- a/Scripts/drone-static-upload.sh +++ b/Scripts/drone-static-upload.sh @@ -3,8 +3,6 @@ # Script used with Drone CI to upload build artifacts (because specifying all this in # .drone.jsonnet is too painful). - - set -o errexit if [ -z "$SSH_KEY" ]; then @@ -35,14 +33,13 @@ prod_path="build/Session.xcarchive" sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app" mkdir -p build -echo "Test" > "build/test.txt" if [ ! -d $prod_path ]; then cp -av $prod_path "$base" else if [ ! -d $sim_path ]; then cp -av $sim_path "$base" else - echo "Expected a file to upload, found none" >&2 + echo -e "\n\n\n\e[31;1mExpected a file to upload, found none\e[0m" >&2 exit 1 fi From 715a5b583fd0357ca6c7e8aa62dc677932745c17 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 15:38:23 +1000 Subject: [PATCH 19/50] Fix a CI script typo --- .drone.jsonnet | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index aa6695992..ebcef9fde 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -29,7 +29,7 @@ local load_cocoapods_cache = { LOOP_BREAK=$((LOOP_BREAK + 1)) if [[ LOOP_BREAK >= 600 ]]; then - 'rm -f /Users/drone/.cocoapods_cache.lock' + rm -f /Users/drone/.cocoapods_cache.lock fi done |||, @@ -54,7 +54,7 @@ local update_cocoapods_cache = { LOOP_BREAK=$((LOOP_BREAK + 1)) if [[ LOOP_BREAK >= 600 ]]; then - 'rm -f /Users/drone/.cocoapods_cache.lock' + rm -f /Users/drone/.cocoapods_cache.lock fi done |||, From 2f05f3f3a29362829197b5a14a4ddf771d8bd82b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 15:43:23 +1000 Subject: [PATCH 20/50] Fixed a couple of invalid checks --- .drone.jsonnet | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index ebcef9fde..e09c16edc 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -28,7 +28,7 @@ local load_cocoapods_cache = { sleep 1 LOOP_BREAK=$((LOOP_BREAK + 1)) - if [[ LOOP_BREAK >= 600 ]]; then + if [[ $LOOP_BREAK -ge 600 ]]; then rm -f /Users/drone/.cocoapods_cache.lock fi done @@ -53,7 +53,7 @@ local update_cocoapods_cache = { sleep 1 LOOP_BREAK=$((LOOP_BREAK + 1)) - if [[ LOOP_BREAK >= 600 ]]; then + if [[ $LOOP_BREAK -ge 600 ]]; then rm -f /Users/drone/.cocoapods_cache.lock fi done From 635a5182bc3c9c204b9d2fe21e6eb86fb720662e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 16:21:02 +1000 Subject: [PATCH 21/50] Fixed a typo in the upload script --- Scripts/drone-static-upload.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Scripts/drone-static-upload.sh b/Scripts/drone-static-upload.sh index 260f8f9af..c7209b2ae 100755 --- a/Scripts/drone-static-upload.sh +++ b/Scripts/drone-static-upload.sh @@ -36,7 +36,7 @@ mkdir -p build if [ ! -d $prod_path ]; then cp -av $prod_path "$base" -else if [ ! -d $sim_path ]; then +elif [ ! -d $sim_path ]; then cp -av $sim_path "$base" else echo -e "\n\n\n\e[31;1mExpected a file to upload, found none\e[0m" >&2 From 66b94778e0af020cf7ba21701189b89b8fc6c2a7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 16:30:34 +1000 Subject: [PATCH 22/50] Fixed the build issues and a bug where a new legacy group wasn't subscribile --- .../PushRegistrationManager.swift | 9 +++---- Session/Notifications/SyncPushTokensJob.swift | 9 +++---- .../MessageSender+ClosedGroups.swift | 25 +++++++++++++------ .../ShareNavController.swift | 4 ++- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 04d4a4e0b..a8ef2c345 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -128,10 +128,7 @@ public enum PushRegistrationError: Error { return true } - // FIXME: Might be nice to try to avoid having this required to run on the main thread (follow a similar approach to the 'SyncPushTokensJob' & `Atomic`?) private func registerForVanillaPushToken() -> AnyPublisher { - AssertIsOnMainThread() - // Use the existing publisher if it exists if let vanillaTokenPublisher: AnyPublisher = self.vanillaTokenPublisher { return vanillaTokenPublisher @@ -139,15 +136,17 @@ public enum PushRegistrationError: Error { .eraseToAnyPublisher() } - UIApplication.shared.registerForRemoteNotifications() - // No pending vanilla token yet; create a new publisher let publisher: AnyPublisher = Deferred { Future { self.vanillaTokenResolver = $0 } } + .shareReplay(1) .eraseToAnyPublisher() self.vanillaTokenPublisher = publisher + // Tell the device to register for remote notifications + DispatchQueue.main.sync { UIApplication.shared.registerForRemoteNotifications() } + return publisher .timeout( .seconds(10), diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 2df9657b3..0420bdb6a 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -58,7 +58,7 @@ public enum SyncPushTokensJob: JobExecutor { guard isUsingFullAPNs else { Just(Storage.shared[.lastRecordedPushToken]) .setFailureType(to: Error.self) - .flatMap { lastRecordedPushToken in + .flatMap { lastRecordedPushToken -> AnyPublisher in if let existingToken: String = lastRecordedPushToken { SNLog("[SyncPushTokensJob] Unregister using last recorded push token: \(redact(existingToken))") return Just(existingToken) @@ -71,7 +71,7 @@ public enum SyncPushTokensJob: JobExecutor { .map { token, _ in token } .eraseToAnyPublisher() } - .flatMap { pushToken in PushNotificationAPI.unregister(Data(hex: pushToken)) } + .flatMap { pushToken in PushNotificationAPI.unsubscribe(token: Data(hex: pushToken)) } .map { // Tell the device to unregister for remote notifications (essentially try to invalidate // the token if needed @@ -102,9 +102,8 @@ public enum SyncPushTokensJob: JobExecutor { PushRegistrationManager.shared.requestPushTokens() .flatMap { (pushToken: String, voipToken: String) -> AnyPublisher in PushNotificationAPI - .register( - with: Data(hex: pushToken), - publicKey: getUserHexEncodedPublicKey(), + .subscribe( + token: Data(hex: pushToken), isForcedUpdate: true ) .retry(3) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index 5cecfca71..c94831b18 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -16,7 +16,7 @@ extension MessageSender { members: Set ) -> AnyPublisher { Storage.shared - .writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData]) in + .writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData], Set) in let userPublicKey: String = getUserHexEncodedPublicKey(db) var members: Set = members @@ -111,21 +111,30 @@ extension MessageSender { interactionId: nil ) } + let allActiveLegacyGroupIds: Set = try ClosedGroup + .select(.threadId) + .filter(!ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%")) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == userPublicKey) + ) + .asRequest(of: String.self) + .fetchSet(db) + .inserting(groupPublicKey) // Insert the new key just to be sure - return (userPublicKey, thread, memberSendData) + return (userPublicKey, thread, memberSendData, allActiveLegacyGroupIds) } - .flatMap { userPublicKey, thread, memberSendData in + .flatMap { userPublicKey, thread, memberSendData, allActiveLegacyGroupIds in Publishers .MergeMany( // Send a closed group update message to all members individually memberSendData .map { MessageSender.sendImmediate(preparedSendData: $0) } .appending( - // Notify the PN server - PushNotificationAPI.performOperation( - .subscribe, - for: thread.id, - publicKey: userPublicKey + // Resubscribe to all legacy groups + PushNotificationAPI.subscribeToLegacyGroups( + currentUserPublicKey: userPublicKey, + legacyGroupIds: allActiveLegacyGroupIds ) ) ) diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 7b0c29ed5..07c698ccb 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -9,6 +9,7 @@ import SignalCoreKit final class ShareNavController: UINavigationController, ShareViewDelegate { public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>? + private let versionMigrationsComplete: Atomic = Atomic(false) // MARK: - Error @@ -84,7 +85,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { /// results in the `AppSetup` not actually running (and the UI not actually being loaded correctly) - in order to avoid this /// we call `checkIsAppReady` explicitly here assuming that either the `AppSetup` _hasn't_ complete or won't ever /// get run - checkIsAppReady() + checkIsAppReady(migrationsCompleted: versionMigrationsComplete.wrappedValue) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -107,6 +108,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { } } + versionMigrationsComplete.mutate { $0 = true } checkIsAppReady(migrationsCompleted: true) } From bf981998002785815690f2a874943742831029af Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 1 Aug 2023 17:07:21 +1000 Subject: [PATCH 23/50] Another incorrect statement in the upload script --- Scripts/drone-static-upload.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Scripts/drone-static-upload.sh b/Scripts/drone-static-upload.sh index c7209b2ae..cc3225461 100755 --- a/Scripts/drone-static-upload.sh +++ b/Scripts/drone-static-upload.sh @@ -34,9 +34,9 @@ sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app" mkdir -p build -if [ ! -d $prod_path ]; then +if [ -d $prod_path ]; then cp -av $prod_path "$base" -elif [ ! -d $sim_path ]; then +elif [ -d $sim_path ]; then cp -av $sim_path "$base" else echo -e "\n\n\n\e[31;1mExpected a file to upload, found none\e[0m" >&2 From 76b37c2ad693fbe80f65c2355228163110d70a5c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 09:16:39 +1000 Subject: [PATCH 24/50] Further tweaks to CI setup Added a suffix to distinguish the output files for upload Attempting to exclude the Sim and AppStore builds from PR builds (they can't upload anyway) --- .drone.jsonnet | 2 ++ Scripts/drone-static-upload.sh | 36 +++++++++++++++++++--------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index e09c16edc..982308b24 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -97,6 +97,7 @@ local update_cocoapods_cache = { type: 'exec', name: 'Simulator Build', platform: { os: 'darwin', arch: 'amd64' }, + when: { event: { exclude: 'pull_request' } }, steps: [ clone_submodules, load_cocoapods_cache, @@ -124,6 +125,7 @@ local update_cocoapods_cache = { type: 'exec', name: 'AppStore Build', platform: { os: 'darwin', arch: 'amd64' }, + when: { event: { exclude: 'pull_request' } }, steps: [ clone_submodules, load_cocoapods_cache, diff --git a/Scripts/drone-static-upload.sh b/Scripts/drone-static-upload.sh index cc3225461..99a03db85 100755 --- a/Scripts/drone-static-upload.sh +++ b/Scripts/drone-static-upload.sh @@ -17,32 +17,36 @@ set -o xtrace # Don't start tracing until *after* we write the ssh key chmod 600 ssh_key -if [ -n "$DRONE_TAG" ]; then - # For a tag build use something like `session-ios-v1.2.3` - base="session-ios-$DRONE_TAG" -else - # Otherwise build a length name from the datetime and commit hash, such as: - # session-ios-20200522T212342Z-04d7dcc54 - base="session-ios-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}" -fi - -mkdir -vp "$base" - -# Copy over the build products +# Define the output paths prod_path="build/Session.xcarchive" sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app" -mkdir -p build - +# Validate the paths exist if [ -d $prod_path ]; then - cp -av $prod_path "$base" + suffix="-store" + target_path=$prod_path elif [ -d $sim_path ]; then - cp -av $sim_path "$base" + suffix="-sim" + target_path=$sim_path else echo -e "\n\n\n\e[31;1mExpected a file to upload, found none\e[0m" >&2 exit 1 fi +if [ -n "$DRONE_TAG" ]; then + # For a tag build use something like `session-ios-v1.2.3` + base="session-ios-$DRONE_TAG-$suffix" +else + # Otherwise build a length name from the datetime and commit hash, such as: + # session-ios-20200522T212342Z-04d7dcc54 + base="session-ios-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}-$suffix" +fi + +# Copy over the build products +mkdir -vp "$base" +mkdir -p build +cp -av $target_path "$base" + # tar dat shiz up yo archive="$base.tar.xz" tar cJvf "$archive" "$base" From c76b391d68448f33045959633da189c75982db61 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 09:23:22 +1000 Subject: [PATCH 25/50] CI structure tweak --- .drone.jsonnet | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 982308b24..4351a2e54 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -97,7 +97,7 @@ local update_cocoapods_cache = { type: 'exec', name: 'Simulator Build', platform: { os: 'darwin', arch: 'amd64' }, - when: { event: { exclude: 'pull_request' } }, + when: { event: { exclude: [ 'pull_request' ] } }, steps: [ clone_submodules, load_cocoapods_cache, @@ -125,7 +125,7 @@ local update_cocoapods_cache = { type: 'exec', name: 'AppStore Build', platform: { os: 'darwin', arch: 'amd64' }, - when: { event: { exclude: 'pull_request' } }, + when: { event: { exclude: [ 'pull_request' ] } }, steps: [ clone_submodules, load_cocoapods_cache, From eb3af31f0c7c94fe748e175a3f8f90b8395ea28a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 09:34:02 +1000 Subject: [PATCH 26/50] Updated pipelines to be conditional based on logic instead of config --- .drone.jsonnet | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 4351a2e54..0e6b593a5 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -69,11 +69,9 @@ local update_cocoapods_cache = { ] }; - -[ - // Unit tests - { - kind: 'pipeline', +// Unit test pipeline +local run_unit_tests = { + kind: 'pipeline', type: 'exec', name: 'Unit Tests', platform: { os: 'darwin', arch: 'amd64' }, @@ -90,14 +88,14 @@ local update_cocoapods_cache = { }, update_cocoapods_cache ], - }, - // Simulator build - { - kind: 'pipeline', +}; + +// Build for simulators +local build_for_simulator = { + kind: 'pipeline', type: 'exec', name: 'Simulator Build', platform: { os: 'darwin', arch: 'amd64' }, - when: { event: { exclude: [ 'pull_request' ] } }, steps: [ clone_submodules, load_cocoapods_cache, @@ -118,14 +116,14 @@ local update_cocoapods_cache = { ] }, ], - }, - // AppStore build (generate an archive to be signed later) - { - kind: 'pipeline', +}; + +// Build for AppStore (generate an archive to be signed later) +local build_for_app_store = { + kind: 'pipeline', type: 'exec', name: 'AppStore Build', platform: { os: 'darwin', arch: 'amd64' }, - when: { event: { exclude: [ 'pull_request' ] } }, steps: [ clone_submodules, load_cocoapods_cache, @@ -146,5 +144,14 @@ local update_cocoapods_cache = { ] }, ], - }, +}; + + +// Setup the actual pipelines we want to run +local isPullRequest = std.extVar('DRONE_PULL_REQUEST'); // Get the pull request status + +[ + run_unit_tests, + if isPullRequest == 'true' then {} else build_for_simulator, + if isPullRequest == 'true' then {} else build_for_app_store, ] \ No newline at end of file From b9512d8c4f05df6388082253669dd7dbf3739b59 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 09:35:42 +1000 Subject: [PATCH 27/50] Tweaks to PR checking logic --- .drone.jsonnet | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 0e6b593a5..a07491702 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -148,10 +148,10 @@ local build_for_app_store = { // Setup the actual pipelines we want to run -local isPullRequest = std.extVar('DRONE_PULL_REQUEST'); // Get the pull request status +local buildEvent = std.extVar('DRONE_BUILD_EVENT'); // Get the event which triggered the build [ run_unit_tests, - if isPullRequest == 'true' then {} else build_for_simulator, - if isPullRequest == 'true' then {} else build_for_app_store, + if buildEvent != 'pull_request' then build_for_simulator else {}, + if buildEvent != 'pull_request' then build_for_app_store else {}, ] \ No newline at end of file From a2c75465c16285d116e9ef9f43bcc7aa69cdf73c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 09:39:52 +1000 Subject: [PATCH 28/50] Further CI logic tweaks --- .drone.jsonnet | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index a07491702..8aa27080f 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -148,10 +148,8 @@ local build_for_app_store = { // Setup the actual pipelines we want to run -local buildEvent = std.extVar('DRONE_BUILD_EVENT'); // Get the event which triggered the build - [ run_unit_tests, - if buildEvent != 'pull_request' then build_for_simulator else {}, - if buildEvent != 'pull_request' then build_for_app_store else {}, + if '$DRONE_BUILD_EVENT' != 'pull_request' then build_for_simulator else {}, + if '$DRONE_BUILD_EVENT' != 'pull_request' then build_for_app_store else {}, ] \ No newline at end of file From b04867705ffe9531826afa29e63a92539c7af3ed Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 09:42:16 +1000 Subject: [PATCH 29/50] Testing values --- .drone.jsonnet | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.drone.jsonnet b/.drone.jsonnet index 8aa27080f..ac43df3c6 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -76,6 +76,13 @@ local run_unit_tests = { name: 'Unit Tests', platform: { os: 'darwin', arch: 'amd64' }, steps: [ + { + name: 'Test', + commands: [ + 'echo $DRONE_BUILD_EVENT', + if '$DRONE_BUILD_EVENT' != 'pull_request' then 'echo "Not PR"' else 'echo "Is PR"', + ] + }, clone_submodules, load_cocoapods_cache, install_cocoapods, From 3d755e71259746643d209fa05052b76c21f408b9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 09:45:10 +1000 Subject: [PATCH 30/50] More CI debugging --- .drone.jsonnet | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index ac43df3c6..a7043aaf9 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -80,7 +80,10 @@ local run_unit_tests = { name: 'Test', commands: [ 'echo $DRONE_BUILD_EVENT', - if '$DRONE_BUILD_EVENT' != 'pull_request' then 'echo "Not PR"' else 'echo "Is PR"', + if "$DRONE_BUILD_EVENT" != "pull_request" then 'echo "Not PR 1"' else 'echo "Is PR 1"', + if $DRONE_BUILD_EVENT != pull_request then 'echo "Not PR 2"' else 'echo "Is PR 2"', + if "$DRONE_BUILD_EVENT" -ne "pull_request" then 'echo "Not PR 3"' else 'echo "Is PR 3"', + if $DRONE_BUILD_EVENT -ne pull_request then 'echo "Not PR 4"' else 'echo "Is PR 4"', ] }, clone_submodules, From ae0597a50ff27eb81847b7be510fb35dd2687811 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 10:02:54 +1000 Subject: [PATCH 31/50] Attempt alternate config base approach --- .drone.jsonnet | 47 ++++++++++++++++------------------------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index a7043aaf9..b2546206e 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -69,23 +69,15 @@ local update_cocoapods_cache = { ] }; -// Unit test pipeline -local run_unit_tests = { - kind: 'pipeline', + +[ + // Unit tests + { + kind: 'pipeline', type: 'exec', name: 'Unit Tests', platform: { os: 'darwin', arch: 'amd64' }, steps: [ - { - name: 'Test', - commands: [ - 'echo $DRONE_BUILD_EVENT', - if "$DRONE_BUILD_EVENT" != "pull_request" then 'echo "Not PR 1"' else 'echo "Is PR 1"', - if $DRONE_BUILD_EVENT != pull_request then 'echo "Not PR 2"' else 'echo "Is PR 2"', - if "$DRONE_BUILD_EVENT" -ne "pull_request" then 'echo "Not PR 3"' else 'echo "Is PR 3"', - if $DRONE_BUILD_EVENT -ne pull_request then 'echo "Not PR 4"' else 'echo "Is PR 4"', - ] - }, clone_submodules, load_cocoapods_cache, install_cocoapods, @@ -98,14 +90,14 @@ local run_unit_tests = { }, update_cocoapods_cache ], -}; - -// Build for simulators -local build_for_simulator = { - kind: 'pipeline', + }, + // Simulator build + { + kind: 'pipeline', type: 'exec', name: 'Simulator Build', platform: { os: 'darwin', arch: 'amd64' }, + trigger: { event: { exclude: [ 'pull_request' ] } }, steps: [ clone_submodules, load_cocoapods_cache, @@ -126,14 +118,14 @@ local build_for_simulator = { ] }, ], -}; - -// Build for AppStore (generate an archive to be signed later) -local build_for_app_store = { - kind: 'pipeline', + }, + // AppStore build (generate an archive to be signed later) + { + kind: 'pipeline', type: 'exec', name: 'AppStore Build', platform: { os: 'darwin', arch: 'amd64' }, + trigger: { event: { exclude: [ 'pull_request' ] } }, steps: [ clone_submodules, load_cocoapods_cache, @@ -154,12 +146,5 @@ local build_for_app_store = { ] }, ], -}; - - -// Setup the actual pipelines we want to run -[ - run_unit_tests, - if '$DRONE_BUILD_EVENT' != 'pull_request' then build_for_simulator else {}, - if '$DRONE_BUILD_EVENT' != 'pull_request' then build_for_app_store else {}, + }, ] \ No newline at end of file From 0ac7f7b3394e8fdf62bab6bacb637c04dc0fc40b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 10:36:40 +1000 Subject: [PATCH 32/50] Remove double dash from suffix --- Scripts/drone-static-upload.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Scripts/drone-static-upload.sh b/Scripts/drone-static-upload.sh index 99a03db85..681e58994 100755 --- a/Scripts/drone-static-upload.sh +++ b/Scripts/drone-static-upload.sh @@ -23,10 +23,10 @@ sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app" # Validate the paths exist if [ -d $prod_path ]; then - suffix="-store" + suffix="store" target_path=$prod_path elif [ -d $sim_path ]; then - suffix="-sim" + suffix="sim" target_path=$sim_path else echo -e "\n\n\n\e[31;1mExpected a file to upload, found none\e[0m" >&2 From 87668d86a1524c8ba984cbb3d6d0ac2273d77cd2 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 2 Aug 2023 14:35:59 +1000 Subject: [PATCH 33/50] Fixed an issue where the device might not reregister for push notifications --- Session/Notifications/SyncPushTokensJob.swift | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 6a5a1c245..5e83996b3 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -31,26 +31,6 @@ public enum SyncPushTokensJob: JobExecutor { return deferred(job, dependencies) } - // We need to check a UIApplication setting which needs to run on the main thread so synchronously - // retrieve the value so we can continue - let isRegisteredForRemoteNotifications: Bool = { - guard !Thread.isMainThread else { - return UIApplication.shared.isRegisteredForRemoteNotifications - } - - return DispatchQueue.main.sync { - return UIApplication.shared.isRegisteredForRemoteNotifications - } - }() - - // Apple's documentation states that we should re-register for notifications on every launch: - // https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/HandlingRemoteNotifications.html#//apple_ref/doc/uid/TP40008194-CH6-SW1 - guard job.behaviour == .runOnce || !isRegisteredForRemoteNotifications else { - SNLog("[SyncPushTokensJob] Deferred due to Fast Mode disabled") - deferred(job, dependencies) // Don't need to do anything if push notifications are already registered - return - } - // Determine if the device has 'Fast Mode' (APNS) enabled let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs] @@ -98,7 +78,10 @@ public enum SyncPushTokensJob: JobExecutor { return } - // Perform device registration + /// Perform device registration + /// + /// **Note:** Apple's documentation states that we should re-register for notifications on every launch: + /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/HandlingRemoteNotifications.html#//apple_ref/doc/uid/TP40008194-CH6-SW1 Logger.info("Re-registering for remote notifications.") PushRegistrationManager.shared.requestPushTokens() .flatMap { (pushToken: String, voipToken: String) -> AnyPublisher in From 0e952b40bb023a17c81529adbe7ddab641fbd11f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 3 Aug 2023 09:09:33 +1000 Subject: [PATCH 34/50] Removed 'useSharedUtilForUserConfig' flag and most legacy config logic --- Session.xcodeproj/project.pbxproj | 8 - Session/Home/HomeVC.swift | 8 - Session/Meta/AppDelegate.swift | 31 -- Session/Onboarding/Onboarding.swift | 7 - SessionMessagingKit/Configuration.swift | 10 +- .../Database/LegacyDatabase/SMKLegacy.swift | 123 +------ .../Migrations/_003_YDBToGRDBMigration.swift | 8 - .../_014_GenerateInitialUserConfigDumps.swift | 2 - .../Jobs/Types/ConfigurationSyncJob.swift | 58 +--- .../ConfigurationMessage+Convenience.swift | 85 ----- .../ConfigurationMessage.swift | 313 +----------------- ...essageReceiver+ConfigurationMessages.swift | 235 ------------- .../Sending & Receiving/MessageReceiver.swift | 9 +- .../Notification+MessageReceiver.swift | 9 - .../Pollers/CurrentUserPoller.swift | 7 +- .../Config Handling/SessionUtil+Shared.swift | 11 +- .../QueryInterfaceRequest+Utilities.swift | 6 +- .../SessionUtil/SessionUtil.swift | 85 +---- .../Utilities/ProfileManager.swift | 25 +- .../General/SNUserDefaults.swift | 1 - SignalUtilitiesKit/Utilities/AppSetup.swift | 6 - 21 files changed, 29 insertions(+), 1018 deletions(-) delete mode 100644 SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift delete mode 100644 SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index cac7103f7..6795e47d3 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -128,7 +128,6 @@ 7B8C44C528B49DDA00FBE25F /* NewConversationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8C44C428B49DDA00FBE25F /* NewConversationVC.swift */; }; 7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */; }; 7B93D06A27CF173D00811CB6 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */; }; - 7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */; }; 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */; }; 7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */; }; 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */; }; @@ -652,7 +651,6 @@ FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */; }; FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */; }; FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FC284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift */; }; - FD5C72FF284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FE284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift */; }; FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7300284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift */; }; FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7302284F0FA50029977D /* MessageReceiver+Calls.swift */; }; FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */; }; @@ -1237,7 +1235,6 @@ 7B8C44C428B49DDA00FBE25F /* NewConversationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewConversationVC.swift; sourceTree = ""; }; 7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Reaction.swift"; sourceTree = ""; }; 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = ""; }; - 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListSheet.swift; sourceTree = ""; }; @@ -1771,7 +1768,6 @@ FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+TypingIndicators.swift"; sourceTree = ""; }; FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+DataExtractionNotification.swift"; sourceTree = ""; }; FD5C72FC284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ExpirationTimers.swift"; sourceTree = ""; }; - FD5C72FE284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ConfigurationMessages.swift"; sourceTree = ""; }; FD5C7300284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+UnsendRequests.swift"; sourceTree = ""; }; FD5C7302284F0FA50029977D /* MessageReceiver+Calls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Calls.swift"; sourceTree = ""; }; FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+VisibleMessages.swift"; sourceTree = ""; }; @@ -2730,7 +2726,6 @@ C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */, FD8ECF8A2935DB4B00C0D1BB /* SharedConfigMessage.swift */, C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */, - 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */, B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */, C300A5E62554B07300555489 /* ExpirationTimerUpdate.swift */, 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */, @@ -4002,7 +3997,6 @@ FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */, FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */, FD5C72FC284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift */, - FD5C72FE284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift */, FD5C7300284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift */, FD5C7302284F0FA50029977D /* MessageReceiver+Calls.swift */, FD5C7304284F0FF30029977D /* MessageReceiver+VisibleMessages.swift */, @@ -5864,7 +5858,6 @@ FD23CE222A661D000000B97C /* OpenGroupAPI+Crypto.swift in Sources */, FD245C652850665400B966DD /* ClosedGroupControlMessage.swift in Sources */, FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */, - 7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */, FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */, C32C5A88256DBCF9003C73A2 /* MessageReceiver+ClosedGroups.swift in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, @@ -5874,7 +5867,6 @@ C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, FD716E682850318E00C96BF4 /* CallMode.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, - FD5C72FF284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift in Sources */, FD37EA0D28AB2A45003AE748 /* _005_FixDeletedMessageReadState.swift in Sources */, 7BAA7B6628D2DE4700AE1489 /* _009_OpenGroupPermission.swift in Sources */, FDC4380927B31D4E00C60D73 /* OpenGroupAPIError.swift in Sources */, diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 50a7b6151..21c87c49a 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -283,14 +283,6 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData // Start polling if needed (i.e. if the user just created or restored their Session ID) if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate { appDelegate.startPollersIfNeeded() - - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - if !SessionUtil.userConfigsEnabled { - // Do this only if we created a new Session ID, or if we already received the initial configuration message - if UserDefaults.standard[.hasSyncedInitialConfiguration] { - appDelegate.syncConfigurationIfNeeded() - } - } } // Onion request path countries cache diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index a11d1f669..df4a88901 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -522,7 +522,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD startPollersIfNeeded() if CurrentAppContext().isMainApp { - syncConfigurationIfNeeded() handleAppActivatedWithOngoingCallIfNeeded() } } @@ -868,36 +867,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD presentingVC.present(callVC, animated: true, completion: nil) } - - // MARK: - Config Sync - - func syncConfigurationIfNeeded() { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard !SessionUtil.userConfigsEnabled else { return } - - let lastSync: Date = (UserDefaults.standard[.lastConfigurationSync] ?? .distantPast) - - guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days - - Storage.shared - .writeAsync( - updates: { db in - ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db)) - }, - completion: { _, result in - switch result { - case .failure: break - case .success: - // Only update the 'lastConfigurationSync' timestamp if we have done the - // first sync (Don't want a new device config sync to override config - // syncs from other devices) - if UserDefaults.standard[.hasSyncedInitialConfiguration] { - UserDefaults.standard[.lastConfigurationSync] = Date() - } - } - } - ) - } } // MARK: - LifecycleMethod diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 123b2fd33..1a5956d29 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -30,13 +30,6 @@ enum Onboarding { _ requestId: UUID, using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { - return Just(nil) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - let userPublicKey: String = getUserHexEncodedPublicKey() return SnodeAPI.getSwarm(for: userPublicKey) diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 0aa4f5d37..314a5ca7e 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -31,14 +31,8 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API _011_AddPendingReadReceipts.self, _012_AddFTSIfNeeded.self, _013_SessionUtilChanges.self, - // Wait until the feature is turned on before doing the migration that generates - // the config dump data - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - (Features.useSharedUtilForUserConfig(db) ? - _014_GenerateInitialUserConfigDumps.self : - (nil as Migration.Type?) - ) - ].compactMap { $0 } + _014_GenerateInitialUserConfigDumps.self + ] ] ) } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 7967974ac..f950fde8f 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -649,23 +649,18 @@ public enum SMKLegacy { @objc(SNConfigurationMessage) internal final class _ConfigurationMessage: _ControlMessage { - internal var closedGroups: Set<_CMClosedGroup> = [] - internal var openGroups: Set = [] internal var displayName: String? internal var profilePictureURL: String? internal var profileKey: Data? - internal var contacts: Set<_CMContact> = [] // MARK: NSCoding public required init?(coder: NSCoder) { super.init(coder: coder) - if let closedGroups = coder.decodeObject(forKey: "closedGroups") as! Set<_CMClosedGroup>? { self.closedGroups = closedGroups } - if let openGroups = coder.decodeObject(forKey: "openGroups") as! Set? { self.openGroups = openGroups } + if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } - if let contacts = coder.decodeObject(forKey: "contacts") as! Set<_CMContact>? { self.contacts = contacts } } public override func encode(with coder: NSCoder) { @@ -679,126 +674,12 @@ public enum SMKLegacy { ConfigurationMessage( displayName: displayName, profilePictureUrl: profilePictureURL, - profileKey: profileKey, - closedGroups: closedGroups - .map { $0.toNonLegacy() } - .asSet(), - openGroups: openGroups, - contacts: contacts - .map { $0.toNonLegacy() } - .asSet() + profileKey: profileKey ) ) } } - // MARK: - Config Message Closed Group - - @objc(CMClosedGroup) - internal final class _CMClosedGroup: NSObject, NSCoding { - internal let publicKey: String - internal let name: String - internal let encryptionKeyPair: SUKLegacy.KeyPair - internal let members: Set - internal let admins: Set - internal let expirationTimer: UInt32 - - // MARK: NSCoding - - public required init?(coder: NSCoder) { - guard - let publicKey = coder.decodeObject(forKey: "publicKey") as! String?, - let name = coder.decodeObject(forKey: "name") as! String?, - let encryptionKeyPair = coder.decodeObject(forKey: "encryptionKeyPair") as! SUKLegacy.KeyPair?, - let members = coder.decodeObject(forKey: "members") as! Set?, - let admins = coder.decodeObject(forKey: "admins") as! Set? - else { return nil } - - self.publicKey = publicKey - self.name = name - self.encryptionKeyPair = encryptionKeyPair - self.members = members - self.admins = admins - self.expirationTimer = (coder.decodeObject(forKey: "expirationTimer") as? UInt32 ?? 0) - } - - public func encode(with coder: NSCoder) { - fatalError("encode(with:) should never be called for legacy types") - } - - // MARK: Non-Legacy Conversion - - internal func toNonLegacy() -> ConfigurationMessage.CMClosedGroup { - return ConfigurationMessage.CMClosedGroup( - publicKey: publicKey, - name: name, - encryptionKeyPublicKey: encryptionKeyPair.publicKey, - encryptionKeySecretKey: encryptionKeyPair.privateKey, - members: members, - admins: admins, - expirationTimer: expirationTimer - ) - } - } - - // MARK: - Config Message Contact - - @objc(SNConfigurationMessageContact) - internal final class _CMContact: NSObject, NSCoding { - internal var publicKey: String? - internal var displayName: String? - internal var profilePictureURL: String? - internal var profileKey: Data? - - internal var hasIsApproved: Bool - internal var isApproved: Bool - internal var hasIsBlocked: Bool - internal var isBlocked: Bool - internal var hasDidApproveMe: Bool - internal var didApproveMe: Bool - - // MARK: NSCoding - - public required init?(coder: NSCoder) { - guard - let publicKey = coder.decodeObject(forKey: "publicKey") as! String?, - let displayName = coder.decodeObject(forKey: "displayName") as! String? - else { return nil } - - self.publicKey = publicKey - self.displayName = displayName - self.profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? - self.profileKey = coder.decodeObject(forKey: "profileKey") as! Data? - self.hasIsApproved = (coder.decodeObject(forKey: "hasIsApproved") as? Bool ?? false) - self.isApproved = (coder.decodeObject(forKey: "isApproved") as? Bool ?? false) - self.hasIsBlocked = (coder.decodeObject(forKey: "hasIsBlocked") as? Bool ?? false) - self.isBlocked = (coder.decodeObject(forKey: "isBlocked") as? Bool ?? false) - self.hasDidApproveMe = (coder.decodeObject(forKey: "hasDidApproveMe") as? Bool ?? false) - self.didApproveMe = (coder.decodeObject(forKey: "didApproveMe") as? Bool ?? false) - } - - public func encode(with coder: NSCoder) { - fatalError("encode(with:) should never be called for legacy types") - } - - // MARK: Non-Legacy Conversion - - internal func toNonLegacy() -> ConfigurationMessage.CMContact { - return ConfigurationMessage.CMContact( - publicKey: publicKey, - displayName: displayName, - profilePictureUrl: profilePictureURL, - profileKey: profileKey, - hasIsApproved: hasIsApproved, - isApproved: isApproved, - hasIsBlocked: hasIsBlocked, - isBlocked: isBlocked, - hasDidApproveMe: hasDidApproveMe, - didApproveMe: didApproveMe - ) - } - } - // MARK: - Unsend Request @objc(SNUnsendRequest) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 8918c1c9b..4873cb107 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -1851,14 +1851,6 @@ enum _003_YDBToGRDBMigration: Migration { SMKLegacy._ConfigurationMessage.self, forClassName: "SNConfigurationMessage" ) - NSKeyedUnarchiver.setClass( - SMKLegacy._CMClosedGroup.self, - forClassName: "SNClosedGroup" - ) - NSKeyedUnarchiver.setClass( - SMKLegacy._CMContact.self, - forClassName: "SNConfigurationMessage.SNConfigurationMessageContact" - ) NSKeyedUnarchiver.setClass( SMKLegacy._UnsendRequest.self, forClassName: "SNUnsendRequest" diff --git a/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift index 04b565056..442431a70 100644 --- a/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift @@ -6,8 +6,6 @@ import SessionUtil import SessionUtilitiesKit /// This migration goes through the current state of the database and generates config dumps for the user config types -/// -/// **Note:** This migration won't be run until the `useSharedUtilForUserConfig` feature flag is enabled enum _014_GenerateInitialUserConfigDumps: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "GenerateInitialUserConfigDumps" diff --git a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift index f19caf473..4b1d8011f 100644 --- a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift @@ -20,10 +20,7 @@ public enum ConfigurationSyncJob: JobExecutor { deferred: @escaping (Job, Dependencies) -> (), using dependencies: Dependencies ) { - guard - SessionUtil.userConfigsEnabled, - Identity.userCompletedRequiredOnboarding() - else { return success(job, true, dependencies) } + guard Identity.userCompletedRequiredOnboarding() else { return success(job, true, dependencies) } // It's possible for multiple ConfigSyncJob's with the same target (user/group) to try to run at the // same time since as soon as one is started we will enqueue a second one, rather than adding dependencies @@ -200,35 +197,6 @@ public extension ConfigurationSyncJob { publicKey: String, dependencies: Dependencies = Dependencies() ) { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db) else { - // If we don't have a userKeyPair (or name) yet then there is no need to sync the - // configuration as the user doesn't fully exist yet (this will get triggered on - // the first launch of a fresh install due to the migrations getting run and a few - // times during onboarding) - guard - Identity.userCompletedRequiredOnboarding(db), - let legacyConfigMessage: Message = try? ConfigurationMessage.getCurrent(db) - else { return } - - let publicKey: String = getUserHexEncodedPublicKey(db) - - dependencies.jobRunner.add( - db, - job: Job( - variant: .messageSend, - threadId: publicKey, - details: MessageSendJob.Details( - destination: Message.Destination.contact(publicKey: publicKey), - message: legacyConfigMessage - ) - ), - canStartJob: true, - using: dependencies - ) - return - } - // Upsert a config sync job if needed dependencies.jobRunner.upsert( db, @@ -268,30 +236,6 @@ public extension ConfigurationSyncJob { } static func run(using dependencies: Dependencies = Dependencies()) -> AnyPublisher { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { - return Storage.shared - .writePublisher { db -> MessageSender.PreparedSendData in - // If we don't have a userKeyPair yet then there is no need to sync the configuration - // as the user doesn't exist yet (this will get triggered on the first launch of a - // fresh install due to the migrations getting run) - guard Identity.userCompletedRequiredOnboarding(db) else { throw StorageError.generic } - - let publicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) - - return try MessageSender.preparedSendData( - db, - message: try ConfigurationMessage.getCurrent(db), - to: Message.Destination.contact(publicKey: publicKey), - namespace: .default, - interactionId: nil, - using: dependencies - ) - } - .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } - .eraseToAnyPublisher() - } - // Trigger the job emitting the result when completed return Deferred { Future { resolver in diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift deleted file mode 100644 index 6c8c5977a..000000000 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit - -extension ConfigurationMessage { - public static func getCurrent(_ db: Database) throws -> ConfigurationMessage { - let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser(db) - let displayName: String = currentUserProfile.name - let profilePictureUrl: String? = currentUserProfile.profilePictureUrl - let profileKey: Data? = currentUserProfile.profileEncryptionKey - let closedGroups: Set = try ClosedGroup.fetchAll(db) - .compactMap { closedGroup -> CMClosedGroup? in - guard let latestKeyPair: ClosedGroupKeyPair = try closedGroup.fetchLatestKeyPair(db) else { - return nil - } - - return CMClosedGroup( - publicKey: closedGroup.publicKey, - name: closedGroup.name, - encryptionKeyPublicKey: latestKeyPair.publicKey, - encryptionKeySecretKey: latestKeyPair.secretKey, - members: try closedGroup.members - .select(GroupMember.Columns.profileId) - .asRequest(of: String.self) - .fetchSet(db), - admins: try closedGroup.admins - .select(GroupMember.Columns.profileId) - .asRequest(of: String.self) - .fetchSet(db), - expirationTimer: (try? DisappearingMessagesConfiguration - .fetchOne(db, id: closedGroup.threadId) - .map { ($0.isEnabled ? UInt32($0.durationSeconds) : 0) }) - .defaulting(to: 0) - ) - } - .asSet() - // The default room promise creates an OpenGroup with an empty `roomToken` value, - // we don't want to start a poller for this as the user hasn't actually joined a room - let openGroups: Set = try OpenGroup - .filter(OpenGroup.Columns.roomToken != "") - .filter(OpenGroup.Columns.isActive) - .fetchAll(db) - .compactMap { openGroup in - SessionUtil.communityUrlFor( - server: openGroup.server, - roomToken: openGroup.roomToken, - publicKey: openGroup.publicKey - ) - } - .asSet() - let contacts: Set = try Contact - .filter(Contact.Columns.id != currentUserProfile.id) - .fetchAll(db) - .map { contact -> CMContact in - // Can just default the 'hasX' values to true as they will be set to this - // when converting to proto anyway - let profile: Profile? = try? Profile.fetchOne(db, id: contact.id) - - return CMContact( - publicKey: contact.id, - displayName: (profile?.name ?? contact.id), - profilePictureUrl: profile?.profilePictureUrl, - profileKey: profile?.profileEncryptionKey, - hasIsApproved: true, - isApproved: contact.isApproved, - hasIsBlocked: true, - isBlocked: contact.isBlocked, - hasDidApproveMe: true, - didApproveMe: contact.didApproveMe - ) - } - .asSet() - - return ConfigurationMessage( - displayName: displayName, - profilePictureUrl: profilePictureUrl, - profileKey: profileKey, - closedGroups: closedGroups, - openGroups: openGroups, - contacts: contacts - ) - } -} diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift index 44977dd52..d161ef92e 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift @@ -6,361 +6,80 @@ import SessionUtilitiesKit public final class ConfigurationMessage: ControlMessage { private enum CodingKeys: String, CodingKey { - case closedGroups - case openGroups case displayName case profilePictureUrl case profileKey - case contacts } - public var closedGroups: Set = [] - public var openGroups: Set = [] public var displayName: String? public var profilePictureUrl: String? public var profileKey: Data? - public var contacts: Set = [] public override var isSelfSendValid: Bool { true } - + // MARK: - Initialization public init( displayName: String?, profilePictureUrl: String?, - profileKey: Data?, - closedGroups: Set, - openGroups: Set, - contacts: Set + profileKey: Data? ) { super.init() - + self.displayName = displayName self.profilePictureUrl = profilePictureUrl self.profileKey = profileKey - self.closedGroups = closedGroups - self.openGroups = openGroups - self.contacts = contacts } - + // MARK: - Codable - + required init(from decoder: Decoder) throws { try super.init(from: decoder) - + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - closedGroups = ((try? container.decode(Set.self, forKey: .closedGroups)) ?? []) - openGroups = ((try? container.decode(Set.self, forKey: .openGroups)) ?? []) + displayName = try? container.decode(String.self, forKey: .displayName) profilePictureUrl = try? container.decode(String.self, forKey: .profilePictureUrl) profileKey = try? container.decode(Data.self, forKey: .profileKey) - contacts = ((try? container.decode(Set.self, forKey: .contacts)) ?? []) } - + public override func encode(to encoder: Encoder) throws { try super.encode(to: encoder) - + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encodeIfPresent(closedGroups, forKey: .closedGroups) - try container.encodeIfPresent(openGroups, forKey: .openGroups) + try container.encodeIfPresent(displayName, forKey: .displayName) try container.encodeIfPresent(profilePictureUrl, forKey: .profilePictureUrl) try container.encodeIfPresent(profileKey, forKey: .profileKey) - try container.encodeIfPresent(contacts, forKey: .contacts) } // MARK: - Proto Conversion public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ConfigurationMessage? { guard let configurationProto = proto.configurationMessage else { return nil } + let displayName = configurationProto.displayName let profilePictureUrl = configurationProto.profilePicture let profileKey = configurationProto.profileKey - let closedGroups = Set(configurationProto.closedGroups.compactMap { CMClosedGroup.fromProto($0) }) - let openGroups = Set(configurationProto.openGroups) - let contacts = Set(configurationProto.contacts.compactMap { CMContact.fromProto($0) }) - + return ConfigurationMessage( displayName: displayName, profilePictureUrl: profilePictureUrl, - profileKey: profileKey, - closedGroups: closedGroups, - openGroups: openGroups, - contacts: contacts + profileKey: profileKey ) } - public override func toProto(_ db: Database) -> SNProtoContent? { - let configurationProto = SNProtoConfigurationMessage.builder() - if let displayName = displayName { configurationProto.setDisplayName(displayName) } - if let profilePictureUrl = profilePictureUrl { configurationProto.setProfilePicture(profilePictureUrl) } - if let profileKey = profileKey { configurationProto.setProfileKey(profileKey) } - configurationProto.setClosedGroups(closedGroups.compactMap { $0.toProto() }) - configurationProto.setOpenGroups([String](openGroups)) - configurationProto.setContacts(contacts.compactMap { $0.toProto() }) - let contentProto = SNProtoContent.builder() - do { - contentProto.setConfigurationMessage(try configurationProto.build()) - return try contentProto.build() - } catch { - SNLog("Couldn't construct configuration proto from: \(self).") - return nil - } - } + public override func toProto(_ db: Database) -> SNProtoContent? { return nil } // MARK: - Description public var description: String { """ - ConfigurationMessage( - closedGroups: \([CMClosedGroup](closedGroups).prettifiedDescription), - openGroups: \([String](openGroups).prettifiedDescription), + LegacyConfigurationMessage( displayName: \(displayName ?? "null"), profilePictureUrl: \(profilePictureUrl ?? "null"), - profileKey: \(profileKey?.toHexString() ?? "null"), - contacts: \([CMContact](contacts).prettifiedDescription) + profileKey: \(profileKey?.toHexString() ?? "null") ) """ } } - -// MARK: - Closed Group - -extension ConfigurationMessage { - public struct CMClosedGroup: Codable, Hashable, CustomStringConvertible { - private enum CodingKeys: String, CodingKey { - case publicKey - case name - case encryptionKeyPublicKey - case encryptionKeySecretKey - case members - case admins - case expirationTimer - } - - public let publicKey: String - public let name: String - public let encryptionKeyPublicKey: Data - public let encryptionKeySecretKey: Data - public let members: Set - public let admins: Set - public let expirationTimer: UInt32 - - public var isValid: Bool { !members.isEmpty && !admins.isEmpty } - - // MARK: - Initialization - - public init( - publicKey: String, - name: String, - encryptionKeyPublicKey: Data, - encryptionKeySecretKey: Data, - members: Set, - admins: Set, - expirationTimer: UInt32 - ) { - self.publicKey = publicKey - self.name = name - self.encryptionKeyPublicKey = encryptionKeyPublicKey - self.encryptionKeySecretKey = encryptionKeySecretKey - self.members = members - self.admins = admins - self.expirationTimer = expirationTimer - } - - // MARK: - Codable - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - publicKey = try container.decode(String.self, forKey: .publicKey) - name = try container.decode(String.self, forKey: .name) - encryptionKeyPublicKey = try container.decode(Data.self, forKey: .encryptionKeyPublicKey) - encryptionKeySecretKey = try container.decode(Data.self, forKey: .encryptionKeySecretKey) - members = try container.decode(Set.self, forKey: .members) - admins = try container.decode(Set.self, forKey: .admins) - expirationTimer = try container.decode(UInt32.self, forKey: .expirationTimer) - } - - public func encode(to encoder: Encoder) throws { - var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(publicKey, forKey: .publicKey) - try container.encode(name, forKey: .name) - try container.encode(encryptionKeyPublicKey, forKey: .encryptionKeyPublicKey) - try container.encode(encryptionKeySecretKey, forKey: .encryptionKeySecretKey) - try container.encode(members, forKey: .members) - try container.encode(admins, forKey: .admins) - try container.encode(expirationTimer, forKey: .expirationTimer) - } - - public static func fromProto(_ proto: SNProtoConfigurationMessageClosedGroup) -> CMClosedGroup? { - guard - let publicKey = proto.publicKey?.toHexString(), - let name = proto.name, - let encryptionKeyPairAsProto = proto.encryptionKeyPair - else { return nil } - - let members = Set(proto.members.map { $0.toHexString() }) - let admins = Set(proto.admins.map { $0.toHexString() }) - let expirationTimer = proto.expirationTimer - let result = CMClosedGroup( - publicKey: publicKey, - name: name, - encryptionKeyPublicKey: encryptionKeyPairAsProto.publicKey, - encryptionKeySecretKey: encryptionKeyPairAsProto.privateKey, - members: members, - admins: admins, - expirationTimer: expirationTimer - ) - - guard result.isValid else { return nil } - return result - } - - public func toProto() -> SNProtoConfigurationMessageClosedGroup? { - guard isValid else { return nil } - let result = SNProtoConfigurationMessageClosedGroup.builder() - result.setPublicKey(Data(hex: publicKey)) - result.setName(name) - do { - let encryptionKeyPairAsProto = try SNProtoKeyPair.builder( - publicKey: encryptionKeyPublicKey, - privateKey: encryptionKeySecretKey - ).build() - result.setEncryptionKeyPair(encryptionKeyPairAsProto) - } catch { - SNLog("Couldn't construct closed group proto from: \(self).") - return nil - } - result.setMembers(members.map { Data(hex: $0) }) - result.setAdmins(admins.map { Data(hex: $0) }) - result.setExpirationTimer(expirationTimer) - do { - return try result.build() - } catch { - SNLog("Couldn't construct closed group proto from: \(self).") - return nil - } - } - - public var description: String { name } - } -} - -// MARK: - Contact - -extension ConfigurationMessage { - public struct CMContact: Codable, Hashable, CustomStringConvertible { - private enum CodingKeys: String, CodingKey { - case publicKey - case displayName - case profilePictureUrl - case profileKey - - case hasIsApproved - case isApproved - case hasIsBlocked - case isBlocked - case hasDidApproveMe - case didApproveMe - } - - public var publicKey: String? - public var displayName: String? - public var profilePictureUrl: String? - public var profileKey: Data? - - public var hasIsApproved: Bool - public var isApproved: Bool - public var hasIsBlocked: Bool - public var isBlocked: Bool - public var hasDidApproveMe: Bool - public var didApproveMe: Bool - - public var isValid: Bool { publicKey != nil && displayName != nil } - - public init( - publicKey: String?, - displayName: String?, - profilePictureUrl: String?, - profileKey: Data?, - hasIsApproved: Bool, - isApproved: Bool, - hasIsBlocked: Bool, - isBlocked: Bool, - hasDidApproveMe: Bool, - didApproveMe: Bool - ) { - self.publicKey = publicKey - self.displayName = displayName - self.profilePictureUrl = profilePictureUrl - self.profileKey = profileKey - self.hasIsApproved = hasIsApproved - self.isApproved = isApproved - self.hasIsBlocked = hasIsBlocked - self.isBlocked = isBlocked - self.hasDidApproveMe = hasDidApproveMe - self.didApproveMe = didApproveMe - } - - // MARK: - Codable - - public init(from decoder: Decoder) throws { - let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - - publicKey = try? container.decode(String.self, forKey: .publicKey) - displayName = try? container.decode(String.self, forKey: .displayName) - profilePictureUrl = try? container.decode(String.self, forKey: .profilePictureUrl) - profileKey = try? container.decode(Data.self, forKey: .profileKey) - - hasIsApproved = try container.decode(Bool.self, forKey: .hasIsApproved) - isApproved = try container.decode(Bool.self, forKey: .isApproved) - hasIsBlocked = try container.decode(Bool.self, forKey: .hasIsBlocked) - isBlocked = try container.decode(Bool.self, forKey: .isBlocked) - hasDidApproveMe = try container.decode(Bool.self, forKey: .hasDidApproveMe) - didApproveMe = try container.decode(Bool.self, forKey: .didApproveMe) - } - - public static func fromProto(_ proto: SNProtoConfigurationMessageContact) -> CMContact? { - let result: CMContact = CMContact( - publicKey: proto.publicKey.toHexString(), - displayName: proto.name, - profilePictureUrl: proto.profilePicture, - profileKey: proto.profileKey, - hasIsApproved: proto.hasIsApproved, - isApproved: proto.isApproved, - hasIsBlocked: proto.hasIsBlocked, - isBlocked: proto.isBlocked, - hasDidApproveMe: proto.hasDidApproveMe, - didApproveMe: proto.didApproveMe - ) - - guard result.isValid else { return nil } - return result - } - - public func toProto() -> SNProtoConfigurationMessageContact? { - guard isValid else { return nil } - guard let publicKey = publicKey, let displayName = displayName else { return nil } - let result = SNProtoConfigurationMessageContact.builder(publicKey: Data(hex: publicKey), name: displayName) - if let profilePictureUrl = profilePictureUrl { result.setProfilePicture(profilePictureUrl) } - if let profileKey = profileKey { result.setProfileKey(profileKey) } - - if hasIsApproved { result.setIsApproved(isApproved) } - if hasIsBlocked { result.setIsBlocked(isBlocked) } - if hasDidApproveMe { result.setDidApproveMe(didApproveMe) } - - do { - return try result.build() - } catch { - SNLog("Couldn't construct contact proto from: \(self).") - return nil - } - } - - public var description: String { displayName ?? "" } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift deleted file mode 100644 index 390bf8381..000000000 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import Sodium -import SessionUIKit -import SessionUtilitiesKit - -extension MessageReceiver { - internal static func handleLegacyConfigurationMessage( - _ db: Database, - message: ConfigurationMessage, - using dependencies: Dependencies - ) throws { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard !SessionUtil.userConfigsEnabled(db) else { - TopBannerController.show(warning: .outdatedUserConfig) - return - } - - let userPublicKey = getUserHexEncodedPublicKey(db) - - guard message.sender == userPublicKey else { return } - - SNLog("Configuration message received.") - - // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to - // seconds to maintain the accuracy) - let isInitialSync: Bool = (!UserDefaults.standard[.hasSyncedInitialConfiguration]) - let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestamp ?? 0) / 1000) - let lastConfigTimestamp: TimeInterval = UserDefaults.standard[.lastConfigurationSync] - .defaulting(to: Date(timeIntervalSince1970: 0)) - .timeIntervalSince1970 - - // Handle user profile changes - try ProfileManager.updateProfileIfNeeded( - db, - publicKey: userPublicKey, - name: message.displayName, - avatarUpdate: { - guard - let profilePictureUrl: String = message.profilePictureUrl, - let profileKey: Data = message.profileKey - else { return .none } - - return .updateTo( - url: profilePictureUrl, - key: profileKey, - fileName: nil - ) - }(), - sentTimestamp: messageSentTimestamp, - calledFromConfigHandling: true, - using: dependencies - ) - - // Create a contact for the current user if needed (also force-approve the current user - // in case the account got into a weird state or restored directly from a migration) - let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) - - if !userContact.isTrusted || !userContact.isApproved || !userContact.didApproveMe { - try userContact.save(db) - try Contact - .filter(id: userPublicKey) - .updateAll( // Handling a config update so don't use `updateAllAndConfig` - db, - Contact.Columns.isTrusted.set(to: true), - Contact.Columns.isApproved.set(to: true), - Contact.Columns.didApproveMe.set(to: true) - ) - } - - if isInitialSync || messageSentTimestamp > lastConfigTimestamp { - if isInitialSync { - UserDefaults.standard[.hasSyncedInitialConfiguration] = true - NotificationCenter.default.post(name: .initialConfigurationMessageReceived, object: nil) - } - - UserDefaults.standard[.lastConfigurationSync] = Date(timeIntervalSince1970: messageSentTimestamp) - - // Contacts - try message.contacts.forEach { contactInfo in - guard let sessionId: String = contactInfo.publicKey else { return } - - // If the contact is a blinded contact then only add them if they haven't already been - // unblinded - if SessionId.Prefix(from: sessionId) == .blinded15 || SessionId.Prefix(from: sessionId) == .blinded25 { - let hasUnblindedContact: Bool = BlindedIdLookup - .filter(BlindedIdLookup.Columns.blindedId == sessionId) - .filter(BlindedIdLookup.Columns.sessionId != nil) - .isNotEmpty(db) - - if hasUnblindedContact { - return - } - } - - // Note: We only update the contact and profile records if the data has actually changed - // in order to avoid triggering UI updates for every thread on the home screen - let contact: Contact = Contact.fetchOrCreate(db, id: sessionId) - let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) - - if - profile.name != contactInfo.displayName || - profile.profilePictureUrl != contactInfo.profilePictureUrl || - profile.profileEncryptionKey != contactInfo.profileKey - { - try profile.save(db) - try Profile - .filter(id: sessionId) - .updateAll( // Handling a config update so don't use `updateAllAndConfig` - db, - [ - Profile.Columns.name.set(to: contactInfo.displayName), - (contactInfo.profilePictureUrl == nil ? nil : - Profile.Columns.profilePictureUrl.set(to: contactInfo.profilePictureUrl) - ), - (contactInfo.profileKey == nil ? nil : - Profile.Columns.profileEncryptionKey.set(to: contactInfo.profileKey) - ) - ].compactMap { $0 } - ) - } - - /// We only update these values if the proto actually has values for them (this is to prevent an - /// edge case where an old client could override the values with default values since they aren't included) - /// - /// **Note:** Since message requests have no reverse, we should only handle setting `isApproved` - /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message - /// swapping `isApproved` and `didApproveMe` to `false` - if - (contactInfo.hasIsApproved && (contact.isApproved != contactInfo.isApproved)) || - (contactInfo.hasIsBlocked && (contact.isBlocked != contactInfo.isBlocked)) || - (contactInfo.hasDidApproveMe && (contact.didApproveMe != contactInfo.didApproveMe)) - { - try contact.save(db) - try Contact - .filter(id: sessionId) - .updateAll( // Handling a config update so don't use `updateAllAndConfig` - db, - [ - (!contactInfo.hasIsApproved || !contactInfo.isApproved ? nil : - Contact.Columns.isApproved.set(to: true) - ), - (!contactInfo.hasIsBlocked ? nil : - Contact.Columns.isBlocked.set(to: contactInfo.isBlocked) - ), - (!contactInfo.hasDidApproveMe || !contactInfo.didApproveMe ? nil : - Contact.Columns.didApproveMe.set(to: contactInfo.didApproveMe) - ) - ].compactMap { $0 } - ) - } - - // If the contact is blocked - if contactInfo.hasIsBlocked && contactInfo.isBlocked { - // If this message changed them to the blocked state and there is an existing thread - // associated with them that is a message request thread then delete it (assume - // that the current user had deleted that message request) - if - contactInfo.isBlocked != contact.isBlocked, // 'contact.isBlocked' will be the old value - let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId), - thread.isMessageRequest(db) - { - _ = try thread.delete(db) - } - } - } - - // Closed groups - // - // Note: Only want to add these for initial sync to avoid re-adding closed groups the user - // intentionally left (any closed groups joined since the first processed sync message should - // get added via the 'handleNewClosedGroup' method anyway as they will have come through in the - // past two weeks) - if isInitialSync { - let existingClosedGroupsIds: [String] = (try? SessionThread - .filter(SessionThread.Columns.variant == SessionThread.Variant.legacyGroup) - .fetchAll(db)) - .defaulting(to: []) - .map { $0.id } - - try message.closedGroups.forEach { closedGroup in - guard !existingClosedGroupsIds.contains(closedGroup.publicKey) else { return } - - let keyPair: KeyPair = KeyPair( - publicKey: closedGroup.encryptionKeyPublicKey.bytes, - secretKey: closedGroup.encryptionKeySecretKey.bytes - ) - - try MessageReceiver.handleNewClosedGroup( - db, - groupPublicKey: closedGroup.publicKey, - name: closedGroup.name, - encryptionKeyPair: keyPair, - members: [String](closedGroup.members), - admins: [String](closedGroup.admins), - expirationTimer: closedGroup.expirationTimer, - formationTimestampMs: message.sentTimestamp!, - calledFromConfigHandling: false, // Legacy config isn't an issue - using: dependencies - ) - } - } - - // Open groups - for openGroupURL in message.openGroups { - if let (room, server, publicKey) = SessionUtil.parseCommunity(url: openGroupURL) { - let successfullyAddedGroup: Bool = OpenGroupManager.shared - .add( - db, - roomToken: room, - server: server, - publicKey: publicKey, - calledFromConfigHandling: true - ) - - if successfullyAddedGroup { - db.afterNextTransactionNested { _ in - OpenGroupManager.shared.performInitialRequestsAfterAdd( - successfullyAddedGroup: successfullyAddedGroup, - roomToken: room, - server: server, - publicKey: publicKey, - calledFromConfigHandling: false - ) - .subscribe(on: OpenGroupAPI.workQueue) - .sinkUntilComplete() - } - } - } - } - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index e1d1b8be8..5310c536b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import Sodium +import SessionUIKit import SessionUtilitiesKit import SessionSnodeKit @@ -242,13 +243,6 @@ public enum MessageReceiver { message: message ) - case let message as ConfigurationMessage: - try MessageReceiver.handleLegacyConfigurationMessage( - db, - message: message, - using: dependencies - ) - case let message as UnsendRequest: try MessageReceiver.handleUnsendRequest( db, @@ -282,6 +276,7 @@ public enum MessageReceiver { ) // SharedConfigMessages should be handled by the 'SharedUtil' instead of this + case is ConfigurationMessage: TopBannerController.show(warning: .outdatedUserConfig) case is SharedConfigMessage: throw MessageReceiverError.invalidSharedConfigMessageHandling default: fatalError() diff --git a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift index d5c5b48fa..df890e31d 100644 --- a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift @@ -3,18 +3,9 @@ import Foundation public extension Notification.Name { - - // FIXME: Remove once `useSharedUtilForUserConfig` is permanent - static let initialConfigurationMessageReceived = Notification.Name("initialConfigurationMessageReceived") static let missedCall = Notification.Name("missedCall") } public extension Notification.Key { static let senderId = Notification.Key("senderId") } - -@objc public extension NSNotification { - - // FIXME: Remove once `useSharedUtilForUserConfig` is permanent - @objc static let initialConfigurationMessageReceived = Notification.Name.initialConfigurationMessageReceived.rawValue as NSString -} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index 3e62ad28c..bf69dd878 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -14,12 +14,7 @@ public final class CurrentUserPoller: Poller { // MARK: - Settings - override var namespaces: [SnodeAPI.Namespace] { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { return [.default] } - - return CurrentUserPoller.namespaces - } + override var namespaces: [SnodeAPI.Namespace] { CurrentUserPoller.namespaces } /// After polling a given snode this many times we always switch to a new one. /// diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift index 909ea9ce7..25b2606e6 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift @@ -50,9 +50,6 @@ internal extension SessionUtil { publicKey: String, change: (UnsafeMutablePointer?) throws -> () ) throws { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db, ignoreRequirementsForRunningMigrations: true) else { return } - // Since we are doing direct memory manipulation we are using an `Atomic` // type which has blocking access in it's `mutate` closure let needsPush: Bool @@ -307,9 +304,6 @@ internal extension SessionUtil { targetConfig: ConfigDump.Variant, changeTimestampMs: Int64 ) -> Bool { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db) else { return true } - let targetPublicKey: String = { switch targetConfig { default: return getUserHexEncodedPublicKey(db) @@ -349,10 +343,7 @@ public extension SessionUtil { threadVariant: SessionThread.Variant, visibleOnly: Bool ) -> Bool { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db) else { return true } - - let userPublicKey: String = getUserHexEncodedPublicKey() + let userPublicKey: String = getUserHexEncodedPublicKey(db) let configVariant: ConfigDump.Variant = { switch threadVariant { case .contact: return (threadId == userPublicKey ? .userProfile : .contacts) diff --git a/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift b/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift index a8be039fe..a7285604e 100644 --- a/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift +++ b/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift @@ -91,11 +91,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table let updatedData: [RowDecoder] = try self.updateAndFetchAll(db, assignments.map { $0.assignment }) // Then check if any of the changes could affect the config - guard - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - SessionUtil.userConfigsEnabled(db, ignoreRequirementsForRunningMigrations: true), - SessionUtil.assignmentsRequireConfigUpdate(assignments) - else { return updatedData } + guard SessionUtil.assignmentsRequireConfigUpdate(assignments) else { return updatedData } defer { // If we changed a column that requires a config update then we may as well automatically diff --git a/SessionMessagingKit/SessionUtil/SessionUtil.swift b/SessionMessagingKit/SessionUtil/SessionUtil.swift index d933238a5..a353e8ed1 100644 --- a/SessionMessagingKit/SessionUtil/SessionUtil.swift +++ b/SessionMessagingKit/SessionUtil/SessionUtil.swift @@ -6,25 +6,6 @@ import SessionSnodeKit import SessionUtil import SessionUtilitiesKit -// MARK: - Features - -public extension Features { - static func useSharedUtilForUserConfig(_ db: Database? = nil) -> Bool { - guard Date().timeIntervalSince1970 < 1690761600 else { return true } - guard !SessionUtil.hasCheckedMigrationsCompleted.wrappedValue else { - return SessionUtil.userConfigsEnabledIgnoringFeatureFlag - } - - if let db: Database = db { - return SessionUtil.refreshingUserConfigsEnabled(db) - } - - return Storage.shared - .read { db in SessionUtil.refreshingUserConfigsEnabled(db) } - .defaulting(to: false) - } -} - // MARK: - SessionUtil public enum SessionUtil { @@ -70,10 +51,7 @@ public enum SessionUtil { /// Returns `true` if there is a config which needs to be pushed, but returns `false` if the configs are all up to date or haven't been /// loaded yet (eg. fresh install) public static var needsSync: Bool { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { return false } - - return configStore + configStore .wrappedValue .contains { _, atomicConf in guard atomicConf.wrappedValue != nil else { return false } @@ -84,56 +62,6 @@ public enum SessionUtil { public static var libSessionVersion: String { String(cString: LIBSESSION_UTIL_VERSION_STR) } - fileprivate static let hasCheckedMigrationsCompleted: Atomic = Atomic(false) - private static let requiredMigrationsCompleted: Atomic = Atomic(false) - private static let requiredMigrationIdentifiers: Set = [ - TargetMigrations.Identifier.messagingKit.key(with: _013_SessionUtilChanges.self), - TargetMigrations.Identifier.messagingKit.key(with: _014_GenerateInitialUserConfigDumps.self) - ] - - public static var userConfigsEnabled: Bool { - return userConfigsEnabled(nil) - } - - public static func userConfigsEnabled(_ db: Database?) -> Bool { - Features.useSharedUtilForUserConfig(db) && - SessionUtil.userConfigsEnabledIgnoringFeatureFlag - } - - public static var userConfigsEnabledIgnoringFeatureFlag: Bool { - SessionUtil.requiredMigrationsCompleted.wrappedValue - } - - internal static func userConfigsEnabled( - _ db: Database, - ignoreRequirementsForRunningMigrations: Bool - ) -> Bool { - // First check if we are enabled regardless of what we want to ignore - guard - Features.useSharedUtilForUserConfig(db), - !SessionUtil.requiredMigrationsCompleted.wrappedValue, - !SessionUtil.refreshingUserConfigsEnabled(db), - ignoreRequirementsForRunningMigrations, - let currentlyRunningMigration: (identifier: TargetMigrations.Identifier, migration: Migration.Type) = Storage.shared.currentlyRunningMigration - else { return true } - - let nonIgnoredMigrationIdentifiers: Set = SessionUtil.requiredMigrationIdentifiers - .removing(currentlyRunningMigration.identifier.key(with: currentlyRunningMigration.migration)) - - return Storage.appliedMigrationIdentifiers(db) - .isSuperset(of: nonIgnoredMigrationIdentifiers) - } - - @discardableResult public static func refreshingUserConfigsEnabled(_ db: Database) -> Bool { - let result: Bool = Storage.appliedMigrationIdentifiers(db) - .isSuperset(of: SessionUtil.requiredMigrationIdentifiers) - - requiredMigrationsCompleted.mutate { $0 = result } - hasCheckedMigrationsCompleted.mutate { $0 = true } - - return result - } - internal static func lastError(_ conf: UnsafeMutablePointer?) -> String { return (conf?.pointee.last_error.map { String(cString: $0) } ?? "Unknown") } @@ -141,9 +69,6 @@ public enum SessionUtil { // MARK: - Loading public static func clearMemoryState() { - // Ensure we have a loaded state before we continue - guard !SessionUtil.configStore.wrappedValue.isEmpty else { return } - SessionUtil.configStore.mutate { confStore in confStore.removeAll() } @@ -169,9 +94,6 @@ public enum SessionUtil { return } - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db, ignoreRequirementsForRunningMigrations: true) else { return } - // Retrieve the existing dumps from the database let existingDumps: Set = ((try? ConfigDump.fetchSet(db)) ?? []) let existingDumpVariants: Set = existingDumps @@ -395,9 +317,6 @@ public enum SessionUtil { } public static func configHashes(for publicKey: String) -> [String] { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled else { return [] } - return Storage.shared .read { db -> Set in guard Identity.userExists(db) else { return [] } @@ -437,8 +356,6 @@ public enum SessionUtil { messages: [SharedConfigMessage], publicKey: String ) throws { - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - guard SessionUtil.userConfigsEnabled(db) else { return } guard !messages.isEmpty else { return } guard !publicKey.isEmpty else { throw MessageReceiverError.noThread } diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 7266b30f6..e8407c8c7 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -509,8 +509,7 @@ public struct ProfileManager { // Name if let name: String = name, !name.isEmpty, name != profile.name { - // FIXME: Remove the `userConfigsEnabled` check once `useSharedUtilForUserConfig` is permanent - if sentTimestamp > profile.lastNameUpdate || (isCurrentUser && (calledFromConfigHandling || !SessionUtil.userConfigsEnabled(db))) { + if sentTimestamp > profile.lastNameUpdate || (isCurrentUser && calledFromConfigHandling) { profileChanges.append(Profile.Columns.name.set(to: name)) profileChanges.append(Profile.Columns.lastNameUpdate.set(to: sentTimestamp)) } @@ -520,8 +519,7 @@ public struct ProfileManager { var avatarNeedsDownload: Bool = false var targetAvatarUrl: String? = nil - // FIXME: Remove the `userConfigsEnabled` check once `useSharedUtilForUserConfig` is permanent - if sentTimestamp > profile.lastProfilePictureUpdate || (isCurrentUser && (calledFromConfigHandling || !SessionUtil.userConfigsEnabled(db))) { + if sentTimestamp > profile.lastProfilePictureUpdate || (isCurrentUser && calledFromConfigHandling) { switch avatarUpdate { case .none: break case .uploadImageData: preconditionFailure("Invalid options for this function") @@ -571,25 +569,6 @@ public struct ProfileManager { profileChanges ) } - // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent - else if !SessionUtil.userConfigsEnabled(db) { - // If we have a contact record for the profile (ie. it's a synced profile) then - // should should send an updated config message, otherwise we should just update - // the local state (the shared util has this logic build in to it's handling) - if (try? Contact.exists(db, id: publicKey)) == true { - try Profile - .filter(id: publicKey) - .updateAllAndConfig(db, profileChanges) - } - else { - try Profile - .filter(id: publicKey) - .updateAll( - db, - profileChanges - ) - } - } else { try Profile .filter(id: publicKey) diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index bc55e8231..7564c9753 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -37,7 +37,6 @@ public enum SNUserDefaults { } public enum Date: Swift.String { - case lastConfigurationSync case lastProfilePictureUpload case lastOpenGroupImageUpdate case lastOpen diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 58f0c6856..f656ba41d 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -84,12 +84,6 @@ public enum AppSetup { ) } - // Refresh the migration state for 'SessionUtil' so it's logic can start running - // correctly when called (doing this here instead of automatically via the - // `SessionUtil.userConfigsEnabled` property to avoid having to use the correct - // method when calling within a database read/write closure) - Storage.shared.read { db in SessionUtil.refreshingUserConfigsEnabled(db) } - migrationsCompletion(result, (needsConfigSync || SessionUtil.needsSync)) // The 'if' is only there to prevent the "variable never read" warning from showing From 1a383ea850caae277aa9d3d3e2e7ab743a07e499 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 9 Aug 2023 15:22:42 +1000 Subject: [PATCH 35/50] Fixed a crash due to an assertion for encryption on the main thread Removed some commented out code --- .../PushRegistrationManager.swift | 8 ++++++-- SessionUtilitiesKit/JobRunner/JobRunner.swift | 20 ------------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index a8ef2c345..de1f6aee3 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -81,7 +81,9 @@ public enum PushRegistrationError: Error { return } - vanillaTokenResolver(Result.success(tokenData)) + DispatchQueue.global(qos: .default).async { + vanillaTokenResolver(Result.success(tokenData)) + } } // Vanilla push token is obtained from the system via AppDelegate @@ -92,7 +94,9 @@ public enum PushRegistrationError: Error { return } - vanillaTokenResolver(Result.failure(error)) + DispatchQueue.global(qos: .default).async { + vanillaTokenResolver(Result.failure(error)) + } } // MARK: helpers diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 69836b196..7c46c2730 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -681,26 +681,6 @@ public final class JobRunner: JobRunnerType { queues.wrappedValue[job.variant]?.removePendingJob(jobId) } - - //public static func hasPendingOrRunningJob( - // with variant: Job.Variant, - // threadId: String? = nil, - // interactionId: Int64? = nil, - // details: T? = nil - //) -> Bool { - // guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false } - // - // // Ensure we can encode the details (if provided) - // let detailsData: Data? = details.map { try? JSONEncoder().encode($0) } - // - // guard details == nil || detailsData != nil else { return false } - // - // return targetQueue.hasPendingOrRunningJobWith( - // threadId: threadId, - // interactionId: interactionId, - // detailsData: detailsData - // ) - //} // MARK: - Convenience From 49f2d3bfe21a43b486ec34544e482b1cc673a3e7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 9 Aug 2023 15:31:07 +1000 Subject: [PATCH 36/50] Removed another couple of main thread assertions --- Session/Notifications/PushRegistrationManager.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index de1f6aee3..b9d3f98c9 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -203,9 +203,8 @@ public enum PushRegistrationError: Error { } public func createVoipRegistryIfNecessary() { - AssertIsOnMainThread() - guard voipRegistry == nil else { return } + let voipRegistry = PKPushRegistry(queue: nil) self.voipRegistry = voipRegistry voipRegistry.desiredPushTypes = [.voIP] @@ -213,8 +212,6 @@ public enum PushRegistrationError: Error { } private func registerForVoipPushToken() -> AnyPublisher { - AssertIsOnMainThread() - // Use the existing publisher if it exists if let voipTokenPublisher: AnyPublisher = self.voipTokenPublisher { return voipTokenPublisher From 5285d8117777b6660fd2fd0888f9af78cb7a3844 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 10 Aug 2023 14:43:40 +1000 Subject: [PATCH 37/50] Fixed a few more PN logic issues Sorted out some more threading issues Removed a redundant SyncPushTokensJob run Fixed an issue where the NotificationServiceExtension could incorrectly setup the database before setting up it's context Fixed a few warnings Removed a bunch of legacy code Refactored the MainAppContext from Objective C into Swift --- Session.xcodeproj/project.pbxproj | 94 +--- .../Calls/Call Management/SessionCall.swift | 2 + .../SessionCallManager+Action.swift | 1 + .../Call Management/SessionCallManager.swift | 1 + .../Views & Modals/IncomingCallBanner.swift | 1 + .../Calls/Views & Modals/MiniCallView.swift | 1 + Session/Closed Groups/EditClosedGroupVC.swift | 1 + .../Context Menu/ContextMenuVC+Action.swift | 1 + .../Conversations/ConversationSearch.swift | 1 + .../ConversationVC+Interaction.swift | 1 + .../Input View/InputViewButton.swift | 1 + .../Message Cells/CallMessageCell.swift | 1 + .../Content Views/MediaAlbumView.swift | 1 + .../Content Views/MediaView.swift | 1 + .../Content Views/TypingIndicatorView.swift | 1 + .../Message Cells/MessageCell.swift | 1 + .../Settings/ThreadSettingsViewModel.swift | 1 + .../Views & Modals/ReactionListSheet.swift | 1 + Session/Emoji/Emoji+Available.swift | 1 + .../MessageRequestsViewController.swift | 1 + .../MessageRequestsViewModel.swift | 1 + Session/Home/New Conversation/NewDMVC.swift | 1 + .../CropScaleImageViewController.swift | 130 +++--- .../DocumentTitleViewController.swift | 1 + .../GIFs/GifPickerCell.swift | 1 + .../GIFs/GifPickerViewController.swift | 1 + .../GIFs/GiphyDownloader.swift | 1 + .../ImagePickerController.swift | 3 + .../MediaDetailViewController.swift | 1 + .../MediaPageViewController.swift | 2 + .../MediaTileViewController.swift | 1 + .../PhotoCaptureViewController.swift | 1 + .../PhotoLibrary.swift | 1 + .../SendMediaNavigationController.swift | 9 +- Session/Meta/AppDelegate.swift | 1 + Session/Meta/AppEnvironment.swift | 1 + Session/Meta/MainAppContext.h | 17 - Session/Meta/MainAppContext.m | 314 -------------- Session/Meta/MainAppContext.swift | 253 +++++++++++ Session/Meta/Signal-Bridging-Header.h | 1 - Session/Notifications/AppNotifications.swift | 2 + .../PushRegistrationManager.swift | 28 +- Session/Notifications/SyncPushTokensJob.swift | 40 +- .../UserNotificationsAdaptee.swift | 1 + Session/Onboarding/Onboarding.swift | 4 +- Session/Onboarding/PNModeVC.swift | 1 + Session/Onboarding/RegisterVC.swift | 1 + .../Settings/AppearanceViewController.swift | 1 + .../Settings/BlockedContactsViewModel.swift | 1 + Session/Settings/ImagePickerHandler.swift | 1 + Session/Settings/NukeDataModal.swift | 1 + Session/Shared/CaptionView.swift | 1 + Session/Shared/FullConversationCell.swift | 1 + .../Shared/Types/SessionCell+Styling.swift | 2 +- Session/Utilities/IP2Country.swift | 9 +- Session/Utilities/MockDataGenerator.swift | 1 + .../UIContextualAction+Utilities.swift | 1 + .../NSENotificationPresenter.swift | 1 + .../NotificationServiceExtension.swift | 35 +- .../NotificationServiceExtensionContext.swift | 9 +- .../ShareAppExtensionContext.swift | 7 +- SessionShareExtension/ThreadPickerVC.swift | 1 + .../Networking/OnionRequestAPI.swift | 2 +- SessionUtilitiesKit/General/AppContext.h | 9 - .../General/SNUserDefaults.swift | 4 +- SignalUtilitiesKit/Configuration.swift | 1 + .../AttachmentApprovalViewController.swift | 1 + .../AttachmentCaptionToolbar.swift | 22 +- .../AttachmentPrepViewController.swift | 2 + .../AttachmentTextToolbar.swift | 24 +- .../Image Editing/ImageEditorCanvasView.swift | 1 + .../ImageEditorCropViewController.swift | 5 +- .../Image Editing/ImageEditorModel.swift | 1 + .../ImageEditorPinchGestureRecognizer.swift | 1 + .../Image Editing/ImageEditorStrokeItem.swift | 1 + .../MediaMessageView.swift | 1 + SignalUtilitiesKit/Meta/SignalUtilitiesKit.h | 10 - .../Screen Lock/ScreenLock.swift | 14 +- SignalUtilitiesKit/Utilities/AppSetup.swift | 1 + SignalUtilitiesKit/Utilities/ByteParser.h | 40 -- SignalUtilitiesKit/Utilities/ByteParser.m | 143 ------ SignalUtilitiesKit/Utilities/FunctionalUtil.h | 27 -- SignalUtilitiesKit/Utilities/FunctionalUtil.m | 98 ----- .../NSURLSessionDataTask+StatusCode.h | 15 - .../NSURLSessionDataTask+StatusCode.m | 18 - SignalUtilitiesKit/Utilities/OWSError.h | 69 --- SignalUtilitiesKit/Utilities/OWSError.m | 68 --- SignalUtilitiesKit/Utilities/OWSOperation.h | 88 ---- SignalUtilitiesKit/Utilities/OWSOperation.m | 253 ----------- .../Utilities/ReachabilityManager.swift | 1 + .../Utilities/SignalIOS.pb.swift | 318 -------------- .../Utilities/SignalIOSProto.swift | 409 ------------------ .../Utilities/SwiftSingletons.swift | 1 + SignalUtilitiesKit/Utilities/TSConstants.h | 78 ---- SignalUtilitiesKit/Utilities/TSConstants.m | 20 - SignalUtilitiesKit/Utilities/UIView+OWS.swift | 1 + .../Utilities/UIViewController+OWS.swift | 1 + 97 files changed, 498 insertions(+), 2254 deletions(-) delete mode 100644 Session/Meta/MainAppContext.h delete mode 100644 Session/Meta/MainAppContext.m create mode 100644 Session/Meta/MainAppContext.swift delete mode 100644 SignalUtilitiesKit/Utilities/ByteParser.h delete mode 100644 SignalUtilitiesKit/Utilities/ByteParser.m delete mode 100644 SignalUtilitiesKit/Utilities/FunctionalUtil.h delete mode 100644 SignalUtilitiesKit/Utilities/FunctionalUtil.m delete mode 100644 SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.h delete mode 100644 SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.m delete mode 100644 SignalUtilitiesKit/Utilities/OWSError.h delete mode 100644 SignalUtilitiesKit/Utilities/OWSError.m delete mode 100644 SignalUtilitiesKit/Utilities/OWSOperation.h delete mode 100644 SignalUtilitiesKit/Utilities/OWSOperation.m delete mode 100644 SignalUtilitiesKit/Utilities/SignalIOS.pb.swift delete mode 100644 SignalUtilitiesKit/Utilities/SignalIOSProto.swift delete mode 100644 SignalUtilitiesKit/Utilities/TSConstants.h delete mode 100644 SignalUtilitiesKit/Utilities/TSConstants.m diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b3fa2f5f6..ea1f920a9 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 3496956021A2FC8100DCFE74 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3496955F21A2FC8100DCFE74 /* CloudKit.framework */; }; 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; }; 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */; }; - 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 34B0796B1FCF46B000E248C2 /* MainAppContext.m */; }; 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; }; 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; }; 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2F1F7ABCF800D7438D /* GifPickerLayout.swift */; }; @@ -314,27 +313,13 @@ C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */; }; C33FDC45255A581F00E217F9 /* AppVersion.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA8B255A57FD00E217F9 /* AppVersion.m */; }; C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */; }; - C33FDC78255A582000E217F9 /* TSConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDABE255A580100E217F9 /* TSConstants.m */; }; C33FDC98255A582000E217F9 /* SwiftSingletons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDADE255A580400E217F9 /* SwiftSingletons.swift */; }; - C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAE0255A580400E217F9 /* ByteParser.m */; }; - C33FDCD1255A582000E217F9 /* FunctionalUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB17255A580800E217F9 /* FunctionalUtil.m */; }; - C33FDCFA255A582000E217F9 /* SignalIOSProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */; }; C33FDD03255A582000E217F9 /* WeakTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB49255A580C00E217F9 /* WeakTimer.swift */; }; C33FDD06255A582000E217F9 /* AppVersion.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB4C255A580D00E217F9 /* AppVersion.h */; settings = {ATTRIBUTES = (Public, ); }; }; C33FDD23255A582000E217F9 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB69255A580F00E217F9 /* FeatureFlags.swift */; }; - C33FDD32255A582000E217F9 /* OWSOperation.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB78255A581000E217F9 /* OWSOperation.m */; }; C33FDD3A255A582000E217F9 /* Notification+Loki.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB80255A581100E217F9 /* Notification+Loki.swift */; }; C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB8F255A581200E217F9 /* ParamParser.swift */; }; - C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBA1255A581400E217F9 /* OWSOperation.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDD6E255A582000E217F9 /* NSURLSessionDataTask+StatusCode.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */; }; C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */; }; - C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */; }; - C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDB3255A582000E217F9 /* OWSError.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDBF9255A581C00E217F9 /* OWSError.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC03255A581D00E217F9 /* ByteParser.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDC5255A582000E217F9 /* OWSError.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC0B255A581D00E217F9 /* OWSError.m */; }; - C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC12255A581E00E217F9 /* TSConstants.h */; settings = {ATTRIBUTES = (Public, ); }; }; - C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDC16255A581E00E217F9 /* FunctionalUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3402FE52559036600EA6424 /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */; }; C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */; }; @@ -837,6 +822,7 @@ FDDCBDAD29E776BF00303C38 /* seed3-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */; }; FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; }; FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; + FDE125232A837E4E002DA685 /* MainAppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE125222A837E4E002DA685 /* MainAppContext.swift */; }; FDE658A129418C7900A33BC1 /* CryptoKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE658A029418C7900A33BC1 /* CryptoKit+Utilities.swift */; }; FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE658A229418E2F00A33BC1 /* KeyPair.swift */; }; FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE6E99729F8E63A00F93C5D /* Accessibility.swift */; }; @@ -1129,8 +1115,6 @@ 3496955F21A2FC8100DCFE74 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = ""; }; 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumView.swift; sourceTree = ""; }; - 34B0796B1FCF46B000E248C2 /* MainAppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MainAppContext.m; sourceTree = ""; }; - 34B0796C1FCF46B000E248C2 /* MainAppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MainAppContext.h; sourceTree = ""; }; 34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = ""; }; 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerViewController.swift; sourceTree = ""; }; @@ -1433,9 +1417,7 @@ C33FDA8E255A57FD00E217F9 /* OWSFileSystem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSFileSystem.m; sourceTree = ""; }; C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseDispatchQueue.swift; sourceTree = ""; }; C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BuildConfiguration.swift; sourceTree = ""; }; - C33FDABE255A580100E217F9 /* TSConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TSConstants.m; sourceTree = ""; }; C33FDADE255A580400E217F9 /* SwiftSingletons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftSingletons.swift; sourceTree = ""; }; - C33FDAE0255A580400E217F9 /* ByteParser.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ByteParser.m; sourceTree = ""; }; C33FDAEF255A580500E217F9 /* NSData+Image.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSData+Image.m"; sourceTree = ""; }; C33FDAF1255A580500E217F9 /* ThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailService.swift; sourceTree = ""; }; C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxiedContentDownloader.swift; sourceTree = ""; }; @@ -1443,7 +1425,6 @@ C33FDAFD255A580600E217F9 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; C33FDB01255A580700E217F9 /* AppReadiness.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppReadiness.h; sourceTree = ""; }; C33FDB14255A580800E217F9 /* OWSMath.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMath.h; sourceTree = ""; }; - C33FDB17255A580800E217F9 /* FunctionalUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FunctionalUtil.m; sourceTree = ""; }; C33FDB1C255A580900E217F9 /* UIImage+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIImage+OWS.h"; sourceTree = ""; }; C33FDB22255A580900E217F9 /* OWSMediaUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSMediaUtils.swift; sourceTree = ""; }; C33FDB29255A580A00E217F9 /* NSData+Image.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSData+Image.h"; sourceTree = ""; }; @@ -1451,7 +1432,6 @@ C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackgroundTask.h; sourceTree = ""; }; C33FDB3A255A580B00E217F9 /* Poller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Poller.swift; sourceTree = ""; }; C33FDB3F255A580C00E217F9 /* String+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+SSK.swift"; sourceTree = ""; }; - C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOSProto.swift; sourceTree = ""; }; C33FDB41255A580C00E217F9 /* MIMETypeUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MIMETypeUtil.m; sourceTree = ""; }; C33FDB49255A580C00E217F9 /* WeakTimer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeakTimer.swift; sourceTree = ""; }; C33FDB4C255A580D00E217F9 /* AppVersion.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppVersion.h; sourceTree = ""; }; @@ -1462,27 +1442,17 @@ C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNUserDefaults.swift; sourceTree = ""; }; C33FDB75255A581000E217F9 /* AppReadiness.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppReadiness.m; sourceTree = ""; }; C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSUserDefaults+OWS.m"; sourceTree = ""; }; - C33FDB78255A581000E217F9 /* OWSOperation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSOperation.m; sourceTree = ""; }; C33FDB80255A581100E217F9 /* Notification+Loki.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+Loki.swift"; sourceTree = ""; }; C33FDB81255A581100E217F9 /* UIImage+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+OWS.m"; sourceTree = ""; }; C33FDB85255A581100E217F9 /* AppContext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppContext.m; sourceTree = ""; }; C33FDB8A255A581200E217F9 /* AppContext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppContext.h; sourceTree = ""; }; C33FDB8F255A581200E217F9 /* ParamParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParamParser.swift; sourceTree = ""; }; - C33FDBA1255A581400E217F9 /* OWSOperation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSOperation.h; sourceTree = ""; }; C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewDraft.swift; sourceTree = ""; }; C33FDBAB255A581500E217F9 /* OWSFileSystem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSFileSystem.h; sourceTree = ""; }; - C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURLSessionDataTask+StatusCode.m"; sourceTree = ""; }; C33FDBB6255A581600E217F9 /* DataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DataSource.m; sourceTree = ""; }; C33FDBBC255A581600E217F9 /* SSKKeychainStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSKKeychainStorage.swift; sourceTree = ""; }; C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; - C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalIOS.pb.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; - C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSURLSessionDataTask+StatusCode.h"; sourceTree = ""; }; - C33FDBF9255A581C00E217F9 /* OWSError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSError.h; sourceTree = ""; }; - C33FDC03255A581D00E217F9 /* ByteParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ByteParser.h; sourceTree = ""; }; - C33FDC0B255A581D00E217F9 /* OWSError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSError.m; sourceTree = ""; }; - C33FDC12255A581E00E217F9 /* TSConstants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TSConstants.h; sourceTree = ""; }; - C33FDC16255A581E00E217F9 /* FunctionalUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FunctionalUtil.h; sourceTree = ""; }; C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBackgroundTask.m; sourceTree = ""; }; C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+Encryption.swift"; sourceTree = ""; }; C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Decryption.swift"; sourceTree = ""; }; @@ -1962,6 +1932,7 @@ FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed3-2023-2y.der"; sourceTree = ""; }; FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchRequest+Utilities.swift"; sourceTree = ""; }; FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; + FDE125222A837E4E002DA685 /* MainAppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainAppContext.swift; sourceTree = ""; }; FDE658A029418C7900A33BC1 /* CryptoKit+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CryptoKit+Utilities.swift"; sourceTree = ""; }; FDE658A229418E2F00A33BC1 /* KeyPair.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyPair.swift; sourceTree = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = ""; }; @@ -2052,7 +2023,6 @@ FDFBB74A2A1EFF4900CA7350 /* Bencode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bencode.swift; sourceTree = ""; }; FDFBB74C2A1F3C4E00CA7350 /* NotificationMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationMetadata.swift; sourceTree = ""; }; FDFBB7532A2023EB00CA7350 /* BencodeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeSpec.swift; sourceTree = ""; }; - FDFC4E1829F1F9A600992FB6 /* libsession-util.xcframework */ = {isa = PBXFileReference; explicitFileType = wrapper.xcframework; includeInIndex = 0; path = "libsession-util.xcframework"; sourceTree = BUILD_DIR; }; FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGeneralCache.swift; sourceTree = ""; }; FDFDE123282D04F20098B17F /* MediaDismissAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDismissAnimationController.swift; sourceTree = ""; }; @@ -3400,34 +3370,20 @@ FD71161D28D9772700B47552 /* UIViewController+OWS.swift */, C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */, C38EF307255B6DBE007E1867 /* UIGestureRecognizer+OWS.swift */, - C33FDBF9255A581C00E217F9 /* OWSError.h */, - C33FDC0B255A581D00E217F9 /* OWSError.m */, - C33FDBA1255A581400E217F9 /* OWSOperation.h */, - C33FDB78255A581000E217F9 /* OWSOperation.m */, C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */, C33FDA6F255A57FA00E217F9 /* ReachabilityManager.swift */, - C33FDBD8255A581900E217F9 /* SignalIOS.pb.swift */, - C33FDB40255A580C00E217F9 /* SignalIOSProto.swift */, - C33FDC12255A581E00E217F9 /* TSConstants.h */, - C33FDABE255A580100E217F9 /* TSConstants.m */, C33FDB4C255A580D00E217F9 /* AppVersion.h */, + C33FDA8B255A57FD00E217F9 /* AppVersion.m */, C38EF3E4255B6DF4007E1867 /* CommonStrings.swift */, C38EF304255B6DBE007E1867 /* ImageCache.swift */, C38EF2F2255B6DBC007E1867 /* Searcher.swift */, C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */, - C33FDA8B255A57FD00E217F9 /* AppVersion.m */, C33FDB69255A580F00E217F9 /* FeatureFlags.swift */, C33FDB80255A581100E217F9 /* Notification+Loki.swift */, - C33FDC16255A581E00E217F9 /* FunctionalUtil.h */, - C33FDB17255A580800E217F9 /* FunctionalUtil.m */, C33FDB8F255A581200E217F9 /* ParamParser.swift */, C33FDADE255A580400E217F9 /* SwiftSingletons.swift */, C33FDB49255A580C00E217F9 /* WeakTimer.swift */, C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */, - C33FDBF6255A581C00E217F9 /* NSURLSessionDataTask+StatusCode.h */, - C33FDBB4255A581600E217F9 /* NSURLSessionDataTask+StatusCode.m */, - C33FDC03255A581D00E217F9 /* ByteParser.h */, - C33FDAE0255A580400E217F9 /* ByteParser.m */, C38EF3DD255B6DF1007E1867 /* UIAlertController+OWS.swift */, C38EF241255B6D67007E1867 /* Collection+OWS.swift */, C38EF3AE255B6DE5007E1867 /* OrderedDictionary.swift */, @@ -3457,8 +3413,7 @@ 34330A581E7875FB00DF2FB9 /* Fonts */, B66DBF4919D5BBC8006EA940 /* Images.xcassets */, 45CB2FA71CB7146C00E1B343 /* Launch Screen.storyboard */, - 34B0796C1FCF46B000E248C2 /* MainAppContext.h */, - 34B0796B1FCF46B000E248C2 /* MainAppContext.m */, + FDE125222A837E4E002DA685 /* MainAppContext.swift */, C3CA3AA0255CDA7000F4C6D4 /* Mnemonic */, B67EBF5C19194AC60084CCFD /* Settings.bundle */, B657DDC91911A40500F45B0C /* Signal.entitlements */, @@ -4495,12 +4450,6 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( - C33FDDB0255A582000E217F9 /* NSURLSessionDataTask+StatusCode.h in Headers */, - C33FDDD0255A582000E217F9 /* FunctionalUtil.h in Headers */, - C33FDD5B255A582000E217F9 /* OWSOperation.h in Headers */, - C33FDDCC255A582000E217F9 /* TSConstants.h in Headers */, - C33FDDBD255A582000E217F9 /* ByteParser.h in Headers */, - C33FDDB3255A582000E217F9 /* OWSError.h in Headers */, C38EF35E255B6DCC007E1867 /* OWSViewController.h in Headers */, C33FD9AF255A548A00E217F9 /* SignalUtilitiesKit.h in Headers */, C33FDD06255A582000E217F9 /* AppVersion.h in Headers */, @@ -4801,6 +4750,7 @@ D221A080169C9E5E00537ABF /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; DefaultBuildSystemTypeForWorkspace = Original; LastSwiftUpdateCheck = 1430; LastTestingUpgradeCheck = 0600; @@ -5562,10 +5512,8 @@ C38EF30C255B6DBF007E1867 /* ScreenLock.swift in Sources */, C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */, C38EF38A255B6DD2007E1867 /* AttachmentCaptionToolbar.swift in Sources */, - C33FDCD1255A582000E217F9 /* FunctionalUtil.m in Sources */, C38EF402255B6DF7007E1867 /* CommonStrings.swift in Sources */, C38EF3C1255B6DE7007E1867 /* ImageEditorBrushViewController.swift in Sources */, - C33FDD32255A582000E217F9 /* OWSOperation.m in Sources */, C3F0A530255C80BC007BE2A3 /* NoopNotificationsManager.swift in Sources */, C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */, C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */, @@ -5573,7 +5521,6 @@ C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */, C38EF407255B6DF7007E1867 /* Toast.swift in Sources */, C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */, - C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */, C33FDC45255A581F00E217F9 /* AppVersion.m in Sources */, C38EF3C7255B6DE7007E1867 /* ImageEditorCanvasView.swift in Sources */, C38EF400255B6DF7007E1867 /* GalleryRailView.swift in Sources */, @@ -5582,7 +5529,6 @@ C38EF3BA255B6DE7007E1867 /* ImageEditorItem.swift in Sources */, C33FDD3A255A582000E217F9 /* Notification+Loki.swift in Sources */, C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */, - C33FDCFA255A582000E217F9 /* SignalIOSProto.swift in Sources */, C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */, C38EF38B255B6DD2007E1867 /* AttachmentPrepViewController.swift in Sources */, C38EF405255B6DF7007E1867 /* OWSButton.swift in Sources */, @@ -5598,9 +5544,7 @@ FD52090B28B59BB4006098F6 /* ScreenLockViewController.swift in Sources */, C38EF401255B6DF7007E1867 /* VideoPlayerView.swift in Sources */, C38EF3BD255B6DE7007E1867 /* ImageEditorTransform.swift in Sources */, - C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */, C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */, - C33FDC78255A582000E217F9 /* TSConstants.m in Sources */, C38EF324255B6DBF007E1867 /* Bench.swift in Sources */, FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */, C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */, @@ -5612,12 +5556,10 @@ C38EF386255B6DD2007E1867 /* AttachmentApprovalInputAccessoryView.swift in Sources */, B8C2B2C82563685C00551B4D /* CircleView.swift in Sources */, C38EF331255B6DBF007E1867 /* UIGestureRecognizer+OWS.swift in Sources */, - C33FDDC5255A582000E217F9 /* OWSError.m in Sources */, FD848B9C284435D7000E298B /* AppSetup.swift in Sources */, C38EF31C255B6DBF007E1867 /* Searcher.swift in Sources */, C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */, C38EF3BE255B6DE7007E1867 /* OrderedDictionary.swift in Sources */, - C33FDD6E255A582000E217F9 /* NSURLSessionDataTask+StatusCode.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6059,10 +6001,10 @@ B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */, + FDE125232A837E4E002DA685 /* MainAppContext.swift in Sources */, 7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */, 34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */, B886B4A92398BA1500211ABE /* QRCode.swift in Sources */, - 34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */, 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */, FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, @@ -6491,7 +6433,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6515,7 +6457,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6563,7 +6505,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6592,7 +6534,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6628,7 +6570,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6651,7 +6593,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -6702,7 +6644,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6730,7 +6672,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -7662,7 +7604,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7700,7 +7642,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -7733,7 +7675,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 421; + CURRENT_PROJECT_VERSION = 422; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7771,7 +7713,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.4.0; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 53b392657..e0b92096e 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -9,6 +9,8 @@ import WebRTC import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit +import SessionUtilitiesKit +import SessionSnodeKit public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { @objc static let isEnabled = true diff --git a/Session/Calls/Call Management/SessionCallManager+Action.swift b/Session/Calls/Call Management/SessionCallManager+Action.swift index 6ac7c49cd..639c00130 100644 --- a/Session/Calls/Call Management/SessionCallManager+Action.swift +++ b/Session/Calls/Call Management/SessionCallManager+Action.swift @@ -2,6 +2,7 @@ import UIKit import GRDB +import SessionUtilitiesKit extension SessionCallManager { @discardableResult diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index c89cd5e23..81b3879a8 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -6,6 +6,7 @@ import GRDB import SessionMessagingKit import SignalCoreKit import SignalUtilitiesKit +import SessionUtilitiesKit public final class SessionCallManager: NSObject, CallManagerProtocol { let provider: CXProvider? diff --git a/Session/Calls/Views & Modals/IncomingCallBanner.swift b/Session/Calls/Views & Modals/IncomingCallBanner.swift index 7646903a2..221531644 100644 --- a/Session/Calls/Views & Modals/IncomingCallBanner.swift +++ b/Session/Calls/Views & Modals/IncomingCallBanner.swift @@ -4,6 +4,7 @@ import UIKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit +import SessionUtilitiesKit final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { private static let swipeToOperateThreshold: CGFloat = 60 diff --git a/Session/Calls/Views & Modals/MiniCallView.swift b/Session/Calls/Views & Modals/MiniCallView.swift index 8d060eed4..7c74a18df 100644 --- a/Session/Calls/Views & Modals/MiniCallView.swift +++ b/Session/Calls/Views & Modals/MiniCallView.swift @@ -3,6 +3,7 @@ import UIKit import WebRTC import SessionUIKit +import SessionUtilitiesKit final class MiniCallView: UIView, RTCVideoViewDelegate { var callVC: CallVC diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 07475227c..2c408877e 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -7,6 +7,7 @@ import DifferenceKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit +import SessionUtilitiesKit final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate { private struct GroupMemberDisplayInfo: FetchableRecord, Equatable, Hashable, Decodable, Differentiable { diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 3fb3d0fb8..327a76c49 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -2,6 +2,7 @@ import UIKit import SessionMessagingKit +import SessionUtilitiesKit extension ContextMenuVC { struct Action { diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 785578104..22c6539b4 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -5,6 +5,7 @@ import GRDB import SignalUtilitiesKit import SignalCoreKit import SessionUIKit +import SessionUtilitiesKit public class StyledSearchController: UISearchController { public override var preferredStatusBarStyle: UIStatusBarStyle { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index bb568d5dc..2b0def3f6 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -11,6 +11,7 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +import SessionSnodeKit extension ConversationVC: InputViewDelegate, diff --git a/Session/Conversations/Input View/InputViewButton.swift b/Session/Conversations/Input View/InputViewButton.swift index 8bf158199..246510fed 100644 --- a/Session/Conversations/Input View/InputViewButton.swift +++ b/Session/Conversations/Input View/InputViewButton.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SessionUtilitiesKit final class InputViewButton: UIView { private let icon: UIImage? diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index ef88e2082..337862eb5 100644 --- a/Session/Conversations/Message Cells/CallMessageCell.swift +++ b/Session/Conversations/Message Cells/CallMessageCell.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SessionMessagingKit +import SessionUtilitiesKit final class CallMessageCell: MessageCell { private static let iconSize: CGFloat = 16 diff --git a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift index 87ee2f937..eb410d306 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift @@ -3,6 +3,7 @@ import UIKit import SessionMessagingKit import SignalCoreKit +import SessionUtilitiesKit public class MediaAlbumView: UIStackView { private let items: [Attachment] diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 0af198314..838eb94d2 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -6,6 +6,7 @@ import SessionUIKit import SessionMessagingKit import SignalCoreKit import SignalUtilitiesKit +import SessionUtilitiesKit public class MediaView: UIView { static let contentMode: UIView.ContentMode = .scaleAspectFill diff --git a/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift b/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift index 9af77393f..08ccc733c 100644 --- a/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift +++ b/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit @objc class TypingIndicatorView: UIStackView { // This represents the spacing between the dots diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 63ccdf71d..e32f7fa7f 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -2,6 +2,7 @@ import UIKit import SessionMessagingKit +import SessionUtilitiesKit public enum SwipeState { case began diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 40792b3fc..24dcf2288 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -9,6 +9,7 @@ import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit import SessionUtilitiesKit +import SessionSnodeKit class ThreadSettingsViewModel: SessionTableViewModel { // MARK: - Config diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index e9e70e7b6..3ab37e2fa 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -5,6 +5,7 @@ import DifferenceKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit +import SessionUtilitiesKit final class ReactionListSheet: BaseVC { public struct ReactionSummary: Hashable, Differentiable { diff --git a/Session/Emoji/Emoji+Available.swift b/Session/Emoji/Emoji+Available.swift index 3619859c1..53df31d8a 100644 --- a/Session/Emoji/Emoji+Available.swift +++ b/Session/Emoji/Emoji+Available.swift @@ -1,5 +1,6 @@ import Foundation import SignalCoreKit +import SessionUtilitiesKit extension Emoji { private static let availableCache: Atomic<[Emoji:Bool]> = Atomic([:]) diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 6b91630da..9684e44b1 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -6,6 +6,7 @@ import DifferenceKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit +import SessionUtilitiesKit class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource { private static let loadingHeaderHeight: CGFloat = 40 diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 08e6eff32..f633e3b36 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import DifferenceKit import SignalUtilitiesKit +import SessionUtilitiesKit public class MessageRequestsViewModel { public typealias SectionModel = ArraySection diff --git a/Session/Home/New Conversation/NewDMVC.swift b/Session/Home/New Conversation/NewDMVC.swift index e4765fee1..d369dd933 100644 --- a/Session/Home/New Conversation/NewDMVC.swift +++ b/Session/Home/New Conversation/NewDMVC.swift @@ -7,6 +7,7 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +import SessionSnodeKit final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, QRScannerDelegate { private var shouldShowBackButton: Bool = true diff --git a/Session/Media Viewing & Editing/CropScaleImageViewController.swift b/Session/Media Viewing & Editing/CropScaleImageViewController.swift index df821b716..d307f9990 100644 --- a/Session/Media Viewing & Editing/CropScaleImageViewController.swift +++ b/Session/Media Viewing & Editing/CropScaleImageViewController.swift @@ -5,6 +5,7 @@ import MediaPlayer import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit // This kind of view is tricky. I've tried to organize things in the // simplest possible way. @@ -359,54 +360,54 @@ import SignalCoreKit @objc func handlePinch(sender: UIPinchGestureRecognizer) { switch sender.state { - case .possible: - break - case .began: - srcTranslationAtPinchStart = srcTranslation - imageScaleAtPinchStart = imageScale + case .possible: break + case .began: + srcTranslationAtPinchStart = srcTranslation + imageScaleAtPinchStart = imageScale - lastPinchLocation = - sender.location(in: sender.view) - lastPinchScale = sender.scale - break - case .changed, .ended: - if sender.numberOfTouches > 1 { - let location = + lastPinchLocation = sender.location(in: sender.view) - let scaleDiff = sender.scale / lastPinchScale - - // Update scaling. - let srcCropSizeBeforeScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale * scaleDiff)) - let srcCropSizeAfterScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - // Since the translation state reflects the "upper left" corner of the crop region, we need to - // adjust the translation when scaling to preserve the "center" of the crop region. - srcTranslation.x += (srcCropSizeBeforeScalePoints.width - srcCropSizeAfterScalePoints.width) * 0.5 - srcTranslation.y += (srcCropSizeBeforeScalePoints.height - srcCropSizeAfterScalePoints.height) * 0.5 - - // Update translation. - let viewSizePoints = imageView.frame.size - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width - - let gestureTranslation = CGPoint(x: location.x - lastPinchLocation.x, - y: location.y - lastPinchLocation.y) - - srcTranslation = CGPoint(x: srcTranslation.x + gestureTranslation.x * -viewToSrcRatio, - y: srcTranslation.y + gestureTranslation.y * -viewToSrcRatio) - - lastPinchLocation = location lastPinchScale = sender.scale - } - break - case .cancelled, .failed: - srcTranslation = srcTranslationAtPinchStart - imageScale = imageScaleAtPinchStart - break + + case .changed, .ended: + if sender.numberOfTouches > 1 { + let location = + sender.location(in: sender.view) + let scaleDiff = sender.scale / lastPinchScale + + // Update scaling. + let srcCropSizeBeforeScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, + height: srcDefaultCropSizePoints.height / imageScale) + imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale * scaleDiff)) + let srcCropSizeAfterScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, + height: srcDefaultCropSizePoints.height / imageScale) + // Since the translation state reflects the "upper left" corner of the crop region, we need to + // adjust the translation when scaling to preserve the "center" of the crop region. + srcTranslation.x += (srcCropSizeBeforeScalePoints.width - srcCropSizeAfterScalePoints.width) * 0.5 + srcTranslation.y += (srcCropSizeBeforeScalePoints.height - srcCropSizeAfterScalePoints.height) * 0.5 + + // Update translation. + let viewSizePoints = imageView.frame.size + let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, + height: srcDefaultCropSizePoints.height / imageScale) + + let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width + + let gestureTranslation = CGPoint(x: location.x - lastPinchLocation.x, + y: location.y - lastPinchLocation.y) + + srcTranslation = CGPoint(x: srcTranslation.x + gestureTranslation.x * -viewToSrcRatio, + y: srcTranslation.y + gestureTranslation.y * -viewToSrcRatio) + + lastPinchLocation = location + lastPinchScale = sender.scale + } + + case .cancelled, .failed: + srcTranslation = srcTranslationAtPinchStart + imageScale = imageScaleAtPinchStart + + @unknown default: break } updateImageLayout() @@ -416,29 +417,28 @@ import SignalCoreKit @objc func handlePan(sender: UIPanGestureRecognizer) { switch sender.state { - case .possible: - break - case .began: - srcTranslationAtPanStart = srcTranslation - break - case .changed, .ended: - let viewSizePoints = imageView.frame.size - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) + case .possible: break + case .began: + srcTranslationAtPanStart = srcTranslation + + case .changed, .ended: + let viewSizePoints = imageView.frame.size + let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, + height: srcDefaultCropSizePoints.height / imageScale) - let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width + let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width - let gestureTranslation = - sender.translation(in: sender.view) + let gestureTranslation = + sender.translation(in: sender.view) - // Update translation. - srcTranslation = CGPoint(x: srcTranslationAtPanStart.x + gestureTranslation.x * -viewToSrcRatio, - y: srcTranslationAtPanStart.y + gestureTranslation.y * -viewToSrcRatio) - break - case .cancelled, .failed: - srcTranslation - = srcTranslationAtPanStart - break + // Update translation. + srcTranslation = CGPoint(x: srcTranslationAtPanStart.x + gestureTranslation.x * -viewToSrcRatio, + y: srcTranslationAtPanStart.y + gestureTranslation.y * -viewToSrcRatio) + + case .cancelled, .failed: + srcTranslation = srcTranslationAtPanStart + + @unknown default: break } updateImageLayout() diff --git a/Session/Media Viewing & Editing/DocumentTitleViewController.swift b/Session/Media Viewing & Editing/DocumentTitleViewController.swift index b651a62cc..12ba3248c 100644 --- a/Session/Media Viewing & Editing/DocumentTitleViewController.swift +++ b/Session/Media Viewing & Editing/DocumentTitleViewController.swift @@ -7,6 +7,7 @@ import DifferenceKit import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift index 0967e8020..20575c640 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift @@ -5,6 +5,7 @@ import Combine import YYImage import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit class GifPickerCell: UICollectionViewCell { diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index 4b2436258..d88e5bdb6 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -6,6 +6,7 @@ import Reachability import SignalUtilitiesKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate { diff --git a/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift b/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift index 3dd50b0c0..3afd8d56a 100644 --- a/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift +++ b/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift @@ -2,6 +2,7 @@ import Foundation import SignalUtilitiesKit +import SessionUtilitiesKit public class GiphyDownloader: ProxiedContentDownloader { diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 37fb7f4a8..6983de804 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -6,6 +6,7 @@ import Photos import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit protocol ImagePickerGridControllerDelegate: AnyObject { func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController) @@ -155,6 +156,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat case .cancelled, .ended, .failed: collectionView.isUserInteractionEnabled = true collectionView.isScrollEnabled = true + + @unknown default: break } } diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index 1a7abeebe..f3eaf40e6 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -6,6 +6,7 @@ import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit import SignalCoreKit +import SessionUtilitiesKit public enum MediaGalleryOption { case sliderEnabled diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index f0c725dae..1e14b2bdd 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -6,6 +6,8 @@ import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit +import SessionSnodeKit class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, InteractivelyDismissableViewController { class DynamicallySizedView: UIView { diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index 393e8411c..98d58dd5f 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -7,6 +7,7 @@ import DifferenceKit import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit public class MediaTileViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { diff --git a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift index 91e43eb61..3dd46b425 100644 --- a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift +++ b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift @@ -6,6 +6,7 @@ import AVFoundation import SessionUIKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit protocol PhotoCaptureViewControllerDelegate: AnyObject { func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 5bc29f7e5..0825a42cf 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -6,6 +6,7 @@ import Photos import CoreServices import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit protocol PhotoLibraryDelegate: AnyObject { func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 3ce3348fc..32cf44c16 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -6,6 +6,7 @@ import Photos import SignalUtilitiesKit import SignalCoreKit import SessionUIKit +import SessionUtilitiesKit class SendMediaNavigationController: UINavigationController { public override var preferredStatusBarStyle: UIStatusBarStyle { @@ -539,8 +540,8 @@ private struct MediaLibrarySelection: Hashable, Equatable { let asset: PHAsset let signalAttachmentPublisher: AnyPublisher - var hashValue: Int { - return asset.hashValue + func hash(into hasher: inout Hasher) { + asset.hash(into: &hasher) } var publisher: AnyPublisher { @@ -559,8 +560,8 @@ private struct MediaLibraryAttachment: Hashable, Equatable { let asset: PHAsset let signalAttachment: SignalAttachment - public var hashValue: Int { - return asset.hashValue + func hash(into hasher: inout Hasher) { + asset.hash(into: &hasher) } public static func == (lhs: MediaLibraryAttachment, rhs: MediaLibraryAttachment) -> Bool { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index a11d1f669..59bbe4914 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -9,6 +9,7 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit import SignalCoreKit +import SessionSnodeKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index afcb1e868..35e906c9f 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -4,6 +4,7 @@ import Foundation import SessionUtilitiesKit import SignalUtilitiesKit import SignalCoreKit +import SessionMessagingKit public class AppEnvironment { diff --git a/Session/Meta/MainAppContext.h b/Session/Meta/MainAppContext.h deleted file mode 100644 index 6fab6a1ad..000000000 --- a/Session/Meta/MainAppContext.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const ReportedApplicationStateDidChangeNotification; - -@interface MainAppContext : NSObject - -- (instancetype)init; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m deleted file mode 100644 index 21daaa5f2..000000000 --- a/Session/Meta/MainAppContext.m +++ /dev/null @@ -1,314 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "MainAppContext.h" -#import "Session-Swift.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplicationStateDidChangeNotification"; - -@interface MainAppContext () - -@property (atomic) UIApplicationState reportedApplicationState; - -@property (nonatomic, nullable) NSMutableArray *appActiveBlocks; - -@end - -#pragma mark - - -@implementation MainAppContext - -@synthesize mainWindow = _mainWindow; -@synthesize appLaunchTime = _appLaunchTime; -@synthesize wasWokenUpByPushNotification = _wasWokenUpByPushNotification; - -- (instancetype)init -{ - self = [super init]; - - if (!self) { - return self; - } - - self.reportedApplicationState = UIApplicationStateInactive; - - _appLaunchTime = [NSDate new]; - _wasWokenUpByPushNotification = false; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillEnterForeground:) - name:UIApplicationWillEnterForegroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidEnterBackground:) - name:UIApplicationDidEnterBackgroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillResignActive:) - name:UIApplicationWillResignActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:UIApplicationDidBecomeActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillTerminate:) - name:UIApplicationWillTerminateNotification - object:nil]; - - self.appActiveBlocks = [NSMutableArray new]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -#pragma mark - Notifications - -- (void)setReportedApplicationState:(UIApplicationState)reportedApplicationState -{ - OWSAssertIsOnMainThread(); - - if (_reportedApplicationState == reportedApplicationState) { - return; - } - _reportedApplicationState = reportedApplicationState; - - [[NSNotificationCenter defaultCenter] postNotificationName:ReportedApplicationStateDidChangeNotification - object:nil - userInfo:nil]; -} - -- (void)applicationWillEnterForeground:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.reportedApplicationState = UIApplicationStateInactive; - - OWSLogInfo(@""); - - [NSNotificationCenter.defaultCenter postNotificationName:OWSApplicationWillEnterForegroundNotification object:nil]; -} - -- (void)applicationDidEnterBackground:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.reportedApplicationState = UIApplicationStateBackground; - - OWSLogInfo(@""); - [DDLog flushLog]; - - [NSNotificationCenter.defaultCenter postNotificationName:OWSApplicationDidEnterBackgroundNotification object:nil]; -} - -- (void)applicationWillResignActive:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.reportedApplicationState = UIApplicationStateInactive; - - OWSLogInfo(@""); - [DDLog flushLog]; - - [NSNotificationCenter.defaultCenter postNotificationName:OWSApplicationWillResignActiveNotification object:nil]; -} - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - self.reportedApplicationState = UIApplicationStateActive; - - OWSLogInfo(@""); - - [NSNotificationCenter.defaultCenter postNotificationName:OWSApplicationDidBecomeActiveNotification object:nil]; - - [self runAppActiveBlocks]; -} - -- (void)applicationWillTerminate:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - OWSLogInfo(@""); - [DDLog flushLog]; -} - -#pragma mark - - -- (BOOL)isMainApp -{ - return YES; -} - -- (BOOL)isMainAppAndActive -{ - return [UIApplication sharedApplication].applicationState == UIApplicationStateActive; -} - -- (BOOL)isShareExtension { - return NO; -} - -- (BOOL)isRTL -{ - // FIXME: We should try to remove this as we've had to add a hack to ensure the first call to this runs on the main thread - static BOOL isRTL = NO; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - isRTL = [[UIApplication sharedApplication] userInterfaceLayoutDirection] - == UIUserInterfaceLayoutDirectionRightToLeft; - }); - return isRTL; -} - -- (void)setStatusBarHidden:(BOOL)isHidden animated:(BOOL)isAnimated -{ - [[UIApplication sharedApplication] setStatusBarHidden:isHidden animated:isAnimated]; -} - -- (CGFloat)statusBarHeight -{ - return [UIApplication sharedApplication].statusBarFrame.size.height; -} - -- (BOOL)isInBackground -{ - return self.reportedApplicationState == UIApplicationStateBackground; -} - -- (BOOL)isAppForegroundAndActive -{ - return self.reportedApplicationState == UIApplicationStateActive; -} - -- (UIBackgroundTaskIdentifier)beginBackgroundTaskWithExpirationHandler: - (BackgroundTaskExpirationHandler)expirationHandler -{ - return [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:expirationHandler]; -} - -- (void)endBackgroundTask:(UIBackgroundTaskIdentifier)backgroundTaskIdentifier -{ - [UIApplication.sharedApplication endBackgroundTask:backgroundTaskIdentifier]; -} - -- (void)ensureSleepBlocking:(BOOL)shouldBeBlocking blockingObjects:(NSArray *)blockingObjects -{ - if (UIApplication.sharedApplication.isIdleTimerDisabled != shouldBeBlocking) { - if (shouldBeBlocking) { - NSMutableString *logString = - [NSMutableString stringWithFormat:@"Blocking sleep because of: %@", blockingObjects.firstObject]; - if (blockingObjects.count > 1) { - [logString appendString:[NSString stringWithFormat:@"(and %lu others)", blockingObjects.count - 1]]; - } - OWSLogInfo(@"%@", logString); - } else { - OWSLogInfo(@"Unblocking Sleep."); - } - } - UIApplication.sharedApplication.idleTimerDisabled = shouldBeBlocking; -} - -- (void)setMainAppBadgeNumber:(NSInteger)value -{ - [[UIApplication sharedApplication] setApplicationIconBadgeNumber:value]; - [[NSUserDefaults sharedLokiProject] setInteger:value forKey:@"currentBadgeNumber"]; - [[NSUserDefaults sharedLokiProject] synchronize]; -} - -- (nullable UIViewController *)frontmostViewController -{ - return UIApplication.sharedApplication.frontmostViewControllerIgnoringAlerts; -} - -- (nullable UIAlertAction *)openSystemSettingsAction -{ - return [UIAlertAction actionWithTitle:CommonStrings.openSettingsButton - accessibilityIdentifier:[NSString stringWithFormat:@"%@.%@", self.class, @"system_settings"] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *_Nonnull action) { - [UIApplication.sharedApplication openSystemSettings]; - }]; -} - -- (void)setNetworkActivityIndicatorVisible:(BOOL)value -{ - [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:value]; -} - -#pragma mark - - -- (void)runNowOrWhenMainAppIsActive:(AppActiveBlock)block -{ - OWSAssertDebug(block); - - [Threading dispatchMainThreadSafe:^{ - if (self.isMainAppAndActive) { - // App active blocks typically will be used to safely access the - // shared data container, so use a background task to protect this - // work. - OWSBackgroundTask *_Nullable backgroundTask = - [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - block(); - OWSAssertDebug(backgroundTask); - backgroundTask = nil; - return; - } - - [self.appActiveBlocks addObject:block]; - }]; -} - -- (void)runAppActiveBlocks -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.isMainAppAndActive); - - // App active blocks typically will be used to safely access the - // shared data container, so use a background task to protect this - // work. - OWSBackgroundTask *_Nullable backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__]; - - NSArray *appActiveBlocks = [self.appActiveBlocks copy]; - [self.appActiveBlocks removeAllObjects]; - for (AppActiveBlock block in appActiveBlocks) { - block(); - } - - OWSAssertDebug(backgroundTask); - backgroundTask = nil; -} - -- (NSString *)appDocumentDirectoryPath -{ - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSURL *documentDirectoryURL = - [[fileManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject]; - return [documentDirectoryURL path]; -} - -- (NSString *)appSharedDataDirectoryPath -{ - NSURL *groupContainerDirectoryURL = - [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:SignalApplicationGroup]; - return [groupContainerDirectoryURL path]; -} - -- (NSUserDefaults *)appUserDefaults -{ - return [[NSUserDefaults alloc] initWithSuiteName:SignalApplicationGroup]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/Session/Meta/MainAppContext.swift b/Session/Meta/MainAppContext.swift new file mode 100644 index 000000000..949176192 --- /dev/null +++ b/Session/Meta/MainAppContext.swift @@ -0,0 +1,253 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SignalCoreKit +import SessionUtilitiesKit + +final class MainAppContext: NSObject, AppContext { + var reportedApplicationState: UIApplication.State + + let appLaunchTime = Date() + let isMainApp: Bool = true + var isMainAppAndActive: Bool { UIApplication.shared.applicationState == .active } + var isShareExtension: Bool = false + var appActiveBlocks: [AppActiveBlock] = [] + + var mainWindow: UIWindow? + var wasWokenUpByPushNotification: Bool = false + + private static var _isRTL: Bool = { + return (UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft) + }() + + var isRTL: Bool { return MainAppContext._isRTL } + + var statusBarHeight: CGFloat { UIApplication.shared.statusBarFrame.size.height } + var openSystemSettingsAction: UIAlertAction? { + let result = UIAlertAction( + title: "OPEN_SETTINGS_BUTTON".localized(), + style: .default + ) { _ in UIApplication.shared.openSystemSettings() } + result.accessibilityIdentifier = "\(type(of: self)).system_settings" + + return result + } + + // MARK: - Initialization + + override init() { + self.reportedApplicationState = .inactive + + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillEnterForeground(notification:)), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidEnterBackground(notification:)), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillResignActive(notification:)), + name: UIApplication.willResignActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(notification:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillTerminate(notification:)), + name: UIApplication.willTerminateNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Notifications + + @objc private func applicationWillEnterForeground(notification: NSNotification) { + AssertIsOnMainThread() + + self.reportedApplicationState = .inactive + OWSLogger.info("") + + NotificationCenter.default.post( + name: .OWSApplicationWillEnterForeground, + object: nil + ) + } + + @objc private func applicationDidEnterBackground(notification: NSNotification) { + AssertIsOnMainThread() + + self.reportedApplicationState = .background + + OWSLogger.info("") + DDLog.flushLog() + + NotificationCenter.default.post( + name: .OWSApplicationDidEnterBackground, + object: nil + ) + } + + @objc private func applicationWillResignActive(notification: NSNotification) { + AssertIsOnMainThread() + + self.reportedApplicationState = .inactive + + OWSLogger.info("") + DDLog.flushLog() + + NotificationCenter.default.post( + name: .OWSApplicationWillResignActive, + object: nil + ) + } + + @objc private func applicationDidBecomeActive(notification: NSNotification) { + AssertIsOnMainThread() + + self.reportedApplicationState = .active + + OWSLogger.info("") + + NotificationCenter.default.post( + name: .OWSApplicationDidBecomeActive, + object: nil + ) + + self.runAppActiveBlocks() + } + + @objc private func applicationWillTerminate(notification: NSNotification) { + AssertIsOnMainThread() + + OWSLogger.info("") + DDLog.flushLog() + } + + // MARK: - AppContext Functions + + func setStatusBarHidden(_ isHidden: Bool, animated isAnimated: Bool) { + UIApplication.shared.setStatusBarHidden(isHidden, with: (isAnimated ? .slide : .none)) + } + + func isAppForegroundAndActive() -> Bool { + return (reportedApplicationState == .active) + } + + func isInBackground() -> Bool { + return (reportedApplicationState == .background) + } + + func beginBackgroundTask(expirationHandler: @escaping BackgroundTaskExpirationHandler) -> UIBackgroundTaskIdentifier { + return UIApplication.shared.beginBackgroundTask(expirationHandler: expirationHandler) + } + + func endBackgroundTask(_ backgroundTaskIdentifier: UIBackgroundTaskIdentifier) { + UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier) + } + + func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjects: [Any]) { + if UIApplication.shared.isIdleTimerDisabled != shouldBeBlocking { + if shouldBeBlocking { + var logString: String = "Blocking sleep because of: \(String(describing: blockingObjects.first))" + + if blockingObjects.count > 1 { + logString = "\(logString) (and \(blockingObjects.count - 1) others)" + } + OWSLogger.info(logString) + } + else { + OWSLogger.info("Unblocking Sleep.") + } + } + UIApplication.shared.isIdleTimerDisabled = shouldBeBlocking + } + + func setMainAppBadgeNumber(_ value: Int) { + UIApplication.shared.applicationIconBadgeNumber = value + UserDefaults.sharedLokiProject?.setValue(value, forKey: "currentBadgeNumber") + } + + func frontmostViewController() -> UIViewController? { + UIApplication.shared.frontmostViewControllerIgnoringAlerts + } + + func setNetworkActivityIndicatorVisible(_ value: Bool) { + UIApplication.shared.isNetworkActivityIndicatorVisible = value + } + + // MARK: - + + func runNowOr(whenMainAppIsActive block: @escaping AppActiveBlock) { + Threading.dispatchMainThreadSafe { [weak self] in + if self?.isMainAppAndActive == true { + // App active blocks typically will be used to safely access the + // shared data container, so use a background task to protect this + // work. + var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: #function) + block() + if backgroundTask != nil { backgroundTask = nil } + return + } + + self?.appActiveBlocks.append(block) + } + } + + func runAppActiveBlocks() { + // App active blocks typically will be used to safely access the + // shared data container, so use a background task to protect this + // work. + var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: #function) + + let appActiveBlocks: [AppActiveBlock] = self.appActiveBlocks + self.appActiveBlocks.removeAll() + + appActiveBlocks.forEach { $0() } + if backgroundTask != nil { backgroundTask = nil } + } + + func appDocumentDirectoryPath() -> String { + let targetPath: String? = FileManager.default + .urls( + for: .documentDirectory, + in: .userDomainMask + ) + .last? + .path + owsAssertDebug(targetPath != nil) + + return (targetPath ?? "") + } + + func appSharedDataDirectoryPath() -> String { + let targetPath: String? = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)? + .path + owsAssertDebug(targetPath != nil) + + return (targetPath ?? "") + } + + func appUserDefaults() -> UserDefaults { + owsAssertDebug(UserDefaults.sharedLokiProject != nil) + + return (UserDefaults.sharedLokiProject ?? UserDefaults.standard) + } +} diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index a745cd256..c9b6f4634 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -7,4 +7,3 @@ #import "OWSBezierPathView.h" #import "OWSMessageTimerView.h" #import "OWSWindowManager.h" -#import "MainAppContext.h" diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index bb8129f9a..2c0774346 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -6,6 +6,8 @@ import GRDB import SessionMessagingKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit +import SessionSnodeKit /// There are two primary components in our system notification integration: /// diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index b9d3f98c9..6645428a5 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -4,8 +4,10 @@ import Foundation import Combine import PushKit import GRDB +import SessionMessagingKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit public enum PushRegistrationError: Error { case assertionError(description: String) @@ -53,8 +55,6 @@ public enum PushRegistrationError: Error { Logger.info("") return registerUserNotificationSettings() - .subscribe(on: DispatchQueue.global(qos: .default)) - .receive(on: DispatchQueue.main) // MUST be on main thread .setFailureType(to: Error.self) .tryFlatMap { _ -> AnyPublisher<(pushToken: String, voipToken: String), Error> in #if targetEnvironment(simulator) @@ -75,26 +75,25 @@ public enum PushRegistrationError: Error { // MARK: Vanilla push token // Vanilla push token is obtained from the system via AppDelegate - public func didReceiveVanillaPushToken(_ tokenData: Data) { + public func didReceiveVanillaPushToken(_ tokenData: Data, using dependencies: Dependencies = Dependencies()) { guard let vanillaTokenResolver = self.vanillaTokenResolver else { owsFailDebug("publisher completion in \(#function) unexpectedly nil") return } - DispatchQueue.global(qos: .default).async { + DispatchQueue.global(qos: .default).async(using: dependencies) { vanillaTokenResolver(Result.success(tokenData)) } } // Vanilla push token is obtained from the system via AppDelegate - @objc - public func didFailToReceiveVanillaPushToken(error: Error) { + public func didFailToReceiveVanillaPushToken(error: Error, using dependencies: Dependencies = Dependencies()) { guard let vanillaTokenResolver = self.vanillaTokenResolver else { owsFailDebug("publisher completion in \(#function) unexpectedly nil") return } - DispatchQueue.global(qos: .default).async { + DispatchQueue.global(qos: .default).async(using: dependencies) { vanillaTokenResolver(Result.failure(error)) } } @@ -115,9 +114,8 @@ public enum PushRegistrationError: Error { * in this case we've verified that we *have* properly registered notification settings. */ private var isSusceptibleToFailedPushRegistration: Bool { - // Only affects users who have disabled both: background refresh *and* notifications - guard UIApplication.shared.backgroundRefreshStatus == .denied else { + guard DispatchQueue.main.sync(execute: { UIApplication.shared.backgroundRefreshStatus }) == .denied else { return false } @@ -142,19 +140,21 @@ public enum PushRegistrationError: Error { // No pending vanilla token yet; create a new publisher let publisher: AnyPublisher = Deferred { - Future { self.vanillaTokenResolver = $0 } + Future { + self.vanillaTokenResolver = $0 + + // Tell the device to register for remote notifications + DispatchQueue.main.sync { UIApplication.shared.registerForRemoteNotifications() } + } } .shareReplay(1) .eraseToAnyPublisher() self.vanillaTokenPublisher = publisher - // Tell the device to register for remote notifications - DispatchQueue.main.sync { UIApplication.shared.registerForRemoteNotifications() } - return publisher .timeout( .seconds(10), - scheduler: DispatchQueue.main, + scheduler: DispatchQueue.global(qos: .default), customError: { PushRegistrationError.timeout } ) .catch { error -> AnyPublisher in diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 5e83996b3..fac7107fa 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -39,29 +39,29 @@ public enum SyncPushTokensJob: JobExecutor { guard isUsingFullAPNs else { Just(dependencies.storage[.lastRecordedPushToken]) .setFailureType(to: Error.self) - .flatMap { lastRecordedPushToken -> AnyPublisher in - if let existingToken: String = lastRecordedPushToken { - SNLog("[SyncPushTokensJob] Unregister using last recorded push token: \(redact(existingToken))") - return Just(existingToken) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - SNLog("[SyncPushTokensJob] Unregister using live token provided from device") - return PushRegistrationManager.shared.requestPushTokens() - .map { token, _ in token } - .eraseToAnyPublisher() - } - .flatMap { pushToken in PushNotificationAPI.unsubscribe(token: Data(hex: pushToken)) } - .map { + .flatMap { lastRecordedPushToken -> AnyPublisher in // Tell the device to unregister for remote notifications (essentially try to invalidate - // the token if needed + // the token if needed - we do this first to avoid wrid race conditions which could be + // triggered by the user immediately re-registering) DispatchQueue.main.sync { UIApplication.shared.unregisterForRemoteNotifications() } + // Clear the old token dependencies.storage.write(using: dependencies) { db in db[.lastRecordedPushToken] = nil } - return () + + // Unregister from our server + if let existingToken: String = lastRecordedPushToken { + SNLog("[SyncPushTokensJob] Unregister using last recorded push token: \(redact(existingToken))") + return PushNotificationAPI.unsubscribe(token: Data(hex: existingToken)) + .map { _ in () } + .eraseToAnyPublisher() + } + + SNLog("[SyncPushTokensJob] No previous token stored just triggering device unregister") + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } .subscribe(on: queue, using: dependencies) .sinkUntilComplete( @@ -151,5 +151,9 @@ extension SyncPushTokensJob { // MARK: - Convenience private func redact(_ string: String) -> String { - return OWSIsDebugBuild() ? string : "[ READACTED \(string.prefix(2))...\(string.suffix(2)) ]" +#if DEBUG + return string +#else + return "[ READACTED \(string.prefix(2))...\(string.suffix(2)) ]" +#endif } diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 15c14a53a..383d1877f 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -6,6 +6,7 @@ import UserNotifications import SessionMessagingKit import SignalCoreKit import SignalUtilitiesKit +import SessionUtilitiesKit class UserNotificationConfig { diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 123b2fd33..8c75f230b 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -258,9 +258,9 @@ enum Onboarding { // Notify the app that registration is complete Identity.didRegister() - // Now that we have registered get the Snode pool and sync push tokens + // Now that we have registered get the Snode pool (just in case) - other non-blocking + // launch jobs will automatically be run because the app activation was triggered GetSnodePoolJob.run() - SyncPushTokensJob.run(uploadOnlyIfStale: false) } } } diff --git a/Session/Onboarding/PNModeVC.swift b/Session/Onboarding/PNModeVC.swift index bf4884a29..c389e7b9b 100644 --- a/Session/Onboarding/PNModeVC.swift +++ b/Session/Onboarding/PNModeVC.swift @@ -6,6 +6,7 @@ import SessionUIKit import SessionMessagingKit import SessionSnodeKit import SignalUtilitiesKit +import SessionUtilitiesKit final class PNModeVC: BaseVC, OptionViewDelegate { private let flow: Onboarding.Flow diff --git a/Session/Onboarding/RegisterVC.swift b/Session/Onboarding/RegisterVC.swift index 52cc441a6..ef9cb8228 100644 --- a/Session/Onboarding/RegisterVC.swift +++ b/Session/Onboarding/RegisterVC.swift @@ -4,6 +4,7 @@ import UIKit import Sodium import SessionUIKit import SignalUtilitiesKit +import SessionUtilitiesKit final class RegisterVC : BaseVC { private var seed: Data! { didSet { updateKeyPair() } } diff --git a/Session/Settings/AppearanceViewController.swift b/Session/Settings/AppearanceViewController.swift index 10336d3ed..cca4a0b8f 100644 --- a/Session/Settings/AppearanceViewController.swift +++ b/Session/Settings/AppearanceViewController.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SignalUtilitiesKit +import SessionUtilitiesKit final class AppearanceViewController: BaseVC { // MARK: - Components diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 871bae502..bf4a46bea 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -6,6 +6,7 @@ import GRDB import DifferenceKit import SessionUIKit import SignalUtilitiesKit +import SessionUtilitiesKit class BlockedContactsViewModel: SessionTableViewModel { // MARK: - Section diff --git a/Session/Settings/ImagePickerHandler.swift b/Session/Settings/ImagePickerHandler.swift index 6182f6fc2..59966f5ed 100644 --- a/Session/Settings/ImagePickerHandler.swift +++ b/Session/Settings/ImagePickerHandler.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigationControllerDelegate { private let onTransition: (UIViewController, TransitionType) -> Void diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 3942b5221..371aba884 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -5,6 +5,7 @@ import SessionUIKit import SessionSnodeKit import SessionMessagingKit import SignalUtilitiesKit +import SessionUtilitiesKit final class NukeDataModal: Modal { // MARK: - Initialization diff --git a/Session/Shared/CaptionView.swift b/Session/Shared/CaptionView.swift index 1b66232ca..004da8593 100644 --- a/Session/Shared/CaptionView.swift +++ b/Session/Shared/CaptionView.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SessionUtilitiesKit public protocol CaptionContainerViewDelegate: AnyObject { func captionContainerViewDidUpdateText(_ captionContainerView: CaptionContainerView) diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index e1e5c5d30..bd8bfafbc 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -4,6 +4,7 @@ import UIKit import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit +import SessionUtilitiesKit public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticCell { public static let mutePrefix: String = "\u{e067} " diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift index 948cd1631..e7a454388 100644 --- a/Session/Shared/Types/SessionCell+Styling.swift +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -1,6 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit import SessionUIKit // MARK: - Main Types diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index b27698f5c..326afc83e 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -1,6 +1,7 @@ import Foundation import GRDB import SessionSnodeKit +import SessionUtilitiesKit final class IP2Country { static var isInitialized = false @@ -12,16 +13,16 @@ final class IP2Country { /// the **lower** bound of an IP range and the "registered_country_geoname_id" column contains the ID of the country corresponding /// to that range. We look up an IP by finding the first index in the network column where the value is greater than the IP we're looking /// up (converted to an integer). The IP we're looking up must then be in the range **before** that range. - private lazy var ipv4Table: [String:[Int]] = { + private lazy var ipv4Table: [String: [Int]] = { let url = Bundle.main.url(forResource: "GeoLite2-Country-Blocks-IPv4", withExtension: nil)! let data = try! Data(contentsOf: url) - return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String:[Int]] + return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String: [Int]] }() - private lazy var countryNamesTable: [String:[String]] = { + private lazy var countryNamesTable: [String: [String]] = { let url = Bundle.main.url(forResource: "GeoLite2-Country-Locations-English", withExtension: nil)! let data = try! Data(contentsOf: url) - return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String:[String]] + return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! [String: [String]] }() // MARK: Lifecycle diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 1cbf94fbe..b268786c3 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import Curve25519Kit import SessionMessagingKit +import SessionUtilitiesKit enum MockDataGenerator { // MARK: - Generation diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 1d33a47c6..85997e42a 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -3,6 +3,7 @@ import UIKit import SessionMessagingKit import SessionUIKit +import SessionUtilitiesKit protocol SwipeActionOptimisticCell { func optimisticUpdate(isMuted: Bool?, isBlocked: Bool?, isPinned: Bool?, hasUnread: Bool?) diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index acff494bb..f091f4eb6 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -5,6 +5,7 @@ import GRDB import UserNotifications import SignalUtilitiesKit import SessionMessagingKit +import SessionUtilitiesKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { private var notifications: [String: UNNotificationRequest] = [:] diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index fa3c9b1e1..b4f40742a 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -9,6 +9,7 @@ import BackgroundTasks import SessionMessagingKit import SignalUtilitiesKit import SignalCoreKit +import SessionUtilitiesKit public final class NotificationServiceExtension: UNNotificationServiceExtension { private var didPerformSetup = false @@ -25,8 +26,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension self.contentHandler = contentHandler self.request = request - Storage.resumeDatabaseAccess() - guard let notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent else { return self.completeSilenty() } @@ -36,10 +35,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension return self.completeSilenty() } + /// Create the context if we don't have it (needed before _any_ interaction with the database) + if !HasAppContext() { + SetCurrentAppContext(NotificationServiceExtensionContext()) + } + let isCallOngoing: Bool = (UserDefaults.sharedLokiProject?[.isCallOngoing]) .defaulting(to: false) // Perform main setup + Storage.resumeDatabaseAccess() DispatchQueue.main.sync { self.setUpIfNecessary() { } } // Handle the push notification @@ -224,20 +229,10 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension didPerformSetup = true - // This should be the first thing we do. - SetCurrentAppContext(NotificationServiceExtensionContext()) - _ = AppVersion.sharedInstance() Cryptography.seedRandom() - // We should never receive a non-voip notification on an app that doesn't support - // app extensions since we have to inform the service we wanted these, so in theory - // this path should never occur. However, the service does have our push token - // so it is possible that could change in the future. If it does, do nothing - // and don't disturb the user. Messages will be processed when they open the app. - guard Storage.shared[.isReadyForAppExtensions] else { return completeSilenty() } - AppSetup.setupEnvironment( appSpecificBlock: { Environment.shared?.notificationsManager.mutate { @@ -247,8 +242,22 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension migrationsCompletion: { [weak self] result, needsConfigSync in switch result { // Only 'NSLog' works in the extension - viewable via Console.app - case .failure: NSLog("[NotificationServiceExtension] Failed to complete migrations") + case .failure(let error): + NSLog("[NotificationServiceExtension] Failed to complete migrations") + self?.completeSilenty() + case .success: + // We should never receive a non-voip notification on an app that doesn't support + // app extensions since we have to inform the service we wanted these, so in theory + // this path should never occur. However, the service does have our push token + // so it is possible that could change in the future. If it does, do nothing + // and don't disturb the user. Messages will be processed when they open the app. + guard Storage.shared[.isReadyForAppExtensions] else { + NSLog("[NotificationServiceExtension] Not ready for extensions") + self?.completeSilenty() + return + } + DispatchQueue.main.async { self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift index 7c150570b..d642a984c 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift @@ -4,6 +4,7 @@ import Foundation import SignalUtilitiesKit +import SessionUtilitiesKit final class NotificationServiceExtensionContext : NSObject, AppContext { let appLaunchTime = Date() @@ -31,10 +32,6 @@ final class NotificationServiceExtensionContext : NSObject, AppContext { func isInBackground() -> Bool { true } func mainApplicationStateOnLaunch() -> UIApplication.State { .inactive } - func appDatabaseBaseDirectoryPath() -> String { - return appSharedDataDirectoryPath() - } - func appDocumentDirectoryPath() -> String { guard let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last else { preconditionFailure("Couldn't get document directory.") @@ -43,14 +40,14 @@ final class NotificationServiceExtensionContext : NSObject, AppContext { } func appSharedDataDirectoryPath() -> String { - guard let groupContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: SignalApplicationGroup) else { + guard let groupContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup) else { preconditionFailure("Couldn't get shared data directory.") } return groupContainerURL.path } func appUserDefaults() -> UserDefaults { - guard let userDefaults = UserDefaults(suiteName: SignalApplicationGroup) else { + guard let userDefaults = UserDefaults.sharedLokiProject else { preconditionFailure("Couldn't set up shared user defaults.") } return userDefaults diff --git a/SessionShareExtension/ShareAppExtensionContext.swift b/SessionShareExtension/ShareAppExtensionContext.swift index 4f3417642..4c647fdc7 100644 --- a/SessionShareExtension/ShareAppExtensionContext.swift +++ b/SessionShareExtension/ShareAppExtensionContext.swift @@ -157,7 +157,7 @@ final class ShareAppExtensionContext: NSObject, AppContext { func appSharedDataDirectoryPath() -> String { let targetPath: String? = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: SignalApplicationGroup)? + .containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)? .path owsAssertDebug(targetPath != nil) @@ -165,10 +165,9 @@ final class ShareAppExtensionContext: NSObject, AppContext { } func appUserDefaults() -> UserDefaults { - let targetUserDefaults: UserDefaults? = UserDefaults(suiteName: SignalApplicationGroup) - owsAssertDebug(targetUserDefaults != nil) + owsAssertDebug(UserDefaults.sharedLokiProject != nil) - return (targetUserDefaults ?? UserDefaults.standard) + return (UserDefaults.sharedLokiProject ?? UserDefaults.standard) } func setStatusBarHidden(_ isHidden: Bool, animated isAnimated: Bool) { diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 3008a89e1..afc92180a 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -7,6 +7,7 @@ import DifferenceKit import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit +import SessionSnodeKit final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableViewDelegate, AttachmentApprovalViewControllerDelegate { private let viewModel: ThreadPickerViewModel = ThreadPickerViewModel() diff --git a/SessionSnodeKit/Networking/OnionRequestAPI.swift b/SessionSnodeKit/Networking/OnionRequestAPI.swift index d07233b29..4d0e13b3d 100644 --- a/SessionSnodeKit/Networking/OnionRequestAPI.swift +++ b/SessionSnodeKit/Networking/OnionRequestAPI.swift @@ -275,7 +275,7 @@ public enum OnionRequestAPI { if let snode = snode { if let path = paths.first(where: { !$0.contains(snode) }) { buildPaths(reusing: paths, using: dependencies) // Re-build paths in the background - .subscribe(on: DispatchQueue.global(qos: .background)) + .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) .sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in }) .store(in: &cancellable) diff --git a/SessionUtilitiesKit/General/AppContext.h b/SessionUtilitiesKit/General/AppContext.h index 82c90d809..dff051bd0 100755 --- a/SessionUtilitiesKit/General/AppContext.h +++ b/SessionUtilitiesKit/General/AppContext.h @@ -2,15 +2,6 @@ NS_ASSUME_NONNULL_BEGIN -static inline BOOL OWSIsDebugBuild() -{ -#ifdef DEBUG - return YES; -#else - return NO; -#endif -} - // These are fired whenever the corresponding "main app" or "app extension" // notification is fired. // diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index bc55e8231..21d729da8 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -62,8 +62,10 @@ public enum SNUserDefaults { } public extension UserDefaults { + public static let applicationGroup: String = "group.com.loki-project.loki-messenger" + @objc static var sharedLokiProject: UserDefaults? { - UserDefaults(suiteName: "group.com.loki-project.loki-messenger") + UserDefaults(suiteName: UserDefaults.applicationGroup) } } diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index 4ab7e2d50..651bd49cd 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -4,6 +4,7 @@ import Foundation import SessionUIKit import SessionSnodeKit import SessionMessagingKit +import SessionUtilitiesKit public enum Configuration { public static func performMainSetup() { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 39f6c73f5..a6b1a8782 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -9,6 +9,7 @@ import CoreServices import SessionUIKit import SessionMessagingKit import SignalCoreKit +import SessionUtilitiesKit public protocol AttachmentApprovalViewControllerDelegate: AnyObject { func attachmentApproval( diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift index 4c3ad9f57..dcd4f2044 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift @@ -4,6 +4,7 @@ import Foundation import UIKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit protocol AttachmentCaptionToolbarDelegate: AnyObject { func attachmentCaptionToolbarDidEdit(_ attachmentCaptionToolbar: AttachmentCaptionToolbar) @@ -150,26 +151,7 @@ class AttachmentCaptionToolbar: UIView, UITextViewDelegate { public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { let existingText: String = textView.text ?? "" let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) - - // Don't complicate things by mixing media attachments with oversize text attachments - guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { - Logger.debug("long text was truncated") - self.lengthLimitLabel.isHidden = false - - // `range` represents the section of the existing text we will replace. We can re-use that space. - // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be - // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is - // to just measure the utf8 encoded bytes of the replaced substring. - let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - - // Accept as much of the input as we can - let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete - if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false - } + self.lengthLimitLabel.isHidden = true // After verifying the byte-length is sufficiently small, verify the character count is within bounds. diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index db65cd7e2..4b45d2e1d 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -5,6 +5,8 @@ import UIKit import AVFoundation import SessionUIKit import SignalCoreKit +import SessionMessagingKit +import SessionUtilitiesKit protocol AttachmentPrepViewControllerDelegate: AnyObject { func prepViewControllerUpdateNavigationBar() diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 79ac67d63..736c9049b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -2,9 +2,10 @@ import Foundation import UIKit -import SessionUIKit -import SignalCoreKit import PureLayout +import SignalCoreKit +import SessionUIKit +import SessionUtilitiesKit // Coincides with Android's max text message length let kMaxMessageBodyCharacterCount = 2000 @@ -228,25 +229,6 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { let existingText: String = textView.text ?? "" let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) - // Don't complicate things by mixing media attachments with oversize text attachments - guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { - Logger.debug("long text was truncated") - self.lengthLimitLabel.isHidden = false - - // `range` represents the section of the existing text we will replace. We can re-use that space. - // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be - // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is - // to just measure the utf8 encoded bytes of the replaced substring. - let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - - // Accept as much of the input as we can - let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete - if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false - } self.lengthLimitLabel.isHidden = true // After verifying the byte-length is sufficiently small, verify the character count is within bounds. diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift index e0acf8bba..903c886cf 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit public class EditorTextLayer: CATextLayer { let itemId: String diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift index ad415e5e6..c2f3585fe 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit public protocol ImageEditorCropViewControllerDelegate: AnyObject { func cropDidComplete(transform: ImageEditorTransform) @@ -200,7 +201,7 @@ class ImageEditorCropViewController: OWSViewController { case .topRight, .bottomRight: cropCornerView.autoPinEdge(toSuperviewEdge: .right) default: - owsFailDebug("Invalid crop region: \(cropRegion)") + owsFailDebug("Invalid crop region: \(String(describing: cropRegion))") } switch cropCornerView.cropRegion { case .topLeft, .topRight: @@ -208,7 +209,7 @@ class ImageEditorCropViewController: OWSViewController { case .bottomLeft, .bottomRight: cropCornerView.autoPinEdge(toSuperviewEdge: .bottom) default: - owsFailDebug("Invalid crop region: \(cropRegion)") + owsFailDebug("Invalid crop region: \(String(describing: cropRegion))") } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index d2c8b062b..9ac29b78b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -2,6 +2,7 @@ import UIKit import SignalCoreKit +import SessionUtilitiesKit // Used to represent undo/redo operations. // diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift index 01e580ecb..8d6551f4b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift @@ -2,6 +2,7 @@ import UIKit import SignalCoreKit +import SessionUtilitiesKit public struct ImageEditorPinchState { public let centroid: CGPoint diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorStrokeItem.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorStrokeItem.swift index f86940d22..50b627eea 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorStrokeItem.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorStrokeItem.swift @@ -3,6 +3,7 @@ // import UIKit +import SessionUtilitiesKit @objc public class ImageEditorStrokeItem: ImageEditorItem { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 6089077cc..f0be90aec 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -8,6 +8,7 @@ import NVActivityIndicatorView import SessionUIKit import SessionMessagingKit import SignalCoreKit +import SessionUtilitiesKit public protocol MediaMessageViewAudioDelegate: AnyObject { func progressChanged(_ progressSeconds: CGFloat, durationSeconds: CGFloat) diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index b856fa9a0..73cd95cff 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -3,15 +3,5 @@ FOUNDATION_EXPORT double SignalUtilitiesKitVersionNumber; FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; -@import SessionMessagingKit; -@import SessionSnodeKit; -@import SessionUtilitiesKit; - #import -#import -#import -#import -#import -#import #import -#import diff --git a/SignalUtilitiesKit/Screen Lock/ScreenLock.swift b/SignalUtilitiesKit/Screen Lock/ScreenLock.swift index c1ed4654a..c87fb370d 100644 --- a/SignalUtilitiesKit/Screen Lock/ScreenLock.swift +++ b/SignalUtilitiesKit/Screen Lock/ScreenLock.swift @@ -7,6 +7,10 @@ import SessionMessagingKit import SignalCoreKit public class ScreenLock { + public enum ScreenLockError: Error { + case general(description: String) + } + public enum Outcome { case success case cancel @@ -54,11 +58,11 @@ public class ScreenLock { switch outcome { case .failure(let error): Logger.error("local authentication failed with error: \(error)") - failure(self.authenticationError(errorDescription: error)) + failure(ScreenLockError.general(description: error)) case .unexpectedFailure(let error): Logger.error("local authentication failed with unexpected error: \(error)") - unexpectedFailure(self.authenticationError(errorDescription: error)) + unexpectedFailure(ScreenLockError.general(description: error)) case .success: Logger.verbose("local authentication succeeded.") @@ -203,11 +207,7 @@ public class ScreenLock { } } - return .failure(error:defaultErrorDescription) - } - - private func authenticationError(errorDescription: String) -> Error { - return OWSErrorWithCodeDescription(.localAuthenticationError, errorDescription) + return .failure(error: defaultErrorDescription) } // MARK: - Context diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index f62f78a89..81eeb4026 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -5,6 +5,7 @@ import GRDB import SessionMessagingKit import SessionUtilitiesKit import SessionUIKit +import SessionSnodeKit public enum AppSetup { private static let hasRun: Atomic = Atomic(false) diff --git a/SignalUtilitiesKit/Utilities/ByteParser.h b/SignalUtilitiesKit/Utilities/ByteParser.h deleted file mode 100644 index c30c8c86c..000000000 --- a/SignalUtilitiesKit/Utilities/ByteParser.h +++ /dev/null @@ -1,40 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ByteParser : NSObject - -@property (nonatomic, readonly) BOOL hasError; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithData:(NSData *)data littleEndian:(BOOL)littleEndian; - -#pragma mark - Short - -- (uint16_t)shortAtIndex:(NSUInteger)index; -- (uint16_t)nextShort; - -#pragma mark - Int - -- (uint32_t)intAtIndex:(NSUInteger)index; -- (uint32_t)nextInt; - -#pragma mark - Long - -- (uint64_t)longAtIndex:(NSUInteger)index; -- (uint64_t)nextLong; - -#pragma mark - - -- (BOOL)readZero:(NSUInteger)length; - -- (nullable NSData *)readBytes:(NSUInteger)length; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/ByteParser.m b/SignalUtilitiesKit/Utilities/ByteParser.m deleted file mode 100644 index 4dd7c38db..000000000 --- a/SignalUtilitiesKit/Utilities/ByteParser.m +++ /dev/null @@ -1,143 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "ByteParser.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ByteParser () - -@property (nonatomic, readonly) BOOL littleEndian; -@property (nonatomic, readonly) NSData *data; -@property (nonatomic) NSUInteger cursor; -@property (nonatomic) BOOL hasError; - -@end - -#pragma mark - - -@implementation ByteParser - -- (instancetype)initWithData:(NSData *)data littleEndian:(BOOL)littleEndian -{ - if (self = [super init]) { - _littleEndian = littleEndian; - _data = data; - } - - return self; -} - -#pragma mark - Short - -- (uint16_t)shortAtIndex:(NSUInteger)index -{ - uint16_t value; - const size_t valueSize = sizeof(value); - OWSAssertDebug(valueSize == 2); - if (index + valueSize > self.data.length) { - self.hasError = YES; - return 0; - } - [self.data getBytes:&value range:NSMakeRange(index, valueSize)]; - if (self.littleEndian) { - return CFSwapInt16LittleToHost(value); - } else { - return CFSwapInt16BigToHost(value); - } -} - -- (uint16_t)nextShort -{ - uint16_t value = [self shortAtIndex:self.cursor]; - self.cursor += sizeof(value); - return value; -} - -#pragma mark - Int - -- (uint32_t)intAtIndex:(NSUInteger)index -{ - uint32_t value; - const size_t valueSize = sizeof(value); - OWSAssertDebug(valueSize == 4); - if (index + valueSize > self.data.length) { - self.hasError = YES; - return 0; - } - [self.data getBytes:&value range:NSMakeRange(index, valueSize)]; - if (self.littleEndian) { - return CFSwapInt32LittleToHost(value); - } else { - return CFSwapInt32BigToHost(value); - } -} - -- (uint32_t)nextInt -{ - uint32_t value = [self intAtIndex:self.cursor]; - self.cursor += sizeof(value); - return value; -} - -#pragma mark - Long - -- (uint64_t)longAtIndex:(NSUInteger)index -{ - uint64_t value; - const size_t valueSize = sizeof(value); - OWSAssertDebug(valueSize == 8); - if (index + valueSize > self.data.length) { - self.hasError = YES; - return 0; - } - [self.data getBytes:&value range:NSMakeRange(index, valueSize)]; - if (self.littleEndian) { - return CFSwapInt64LittleToHost(value); - } else { - return CFSwapInt64BigToHost(value); - } -} - -- (uint64_t)nextLong -{ - uint64_t value = [self longAtIndex:self.cursor]; - self.cursor += sizeof(value); - return value; -} - -#pragma mark - - -- (BOOL)readZero:(NSUInteger)length -{ - NSData *_Nullable subdata = [self readBytes:length]; - if (!subdata) { - return NO; - } - uint8_t bytes[length]; - [subdata getBytes:bytes range:NSMakeRange(0, length)]; - for (int i = 0; i < length; i++) { - if (bytes[i] != 0) { - return NO; - } - } - return YES; -} - -- (nullable NSData *)readBytes:(NSUInteger)length -{ - NSUInteger index = self.cursor; - if (index + length > self.data.length) { - self.hasError = YES; - return nil; - } - NSData *_Nullable subdata = [self.data subdataWithRange:NSMakeRange(index, length)]; - self.cursor += length; - return subdata; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/FunctionalUtil.h b/SignalUtilitiesKit/Utilities/FunctionalUtil.h deleted file mode 100644 index e86ed911a..000000000 --- a/SignalUtilitiesKit/Utilities/FunctionalUtil.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSArray (FunctionalUtil) - -/// Returns true when any of the items in this array match the given predicate. -- (bool)any:(int (^)(id item))predicate; - -/// Returns true when all of the items in this array match the given predicate. -- (bool)all:(int (^)(id item))predicate; - -/// Returns an array of all the results of passing items from this array through the given projection function. -- (NSArray *)map:(id (^)(id item))projection; - -/// Returns an array of all the results of passing items from this array through the given projection function. -- (NSArray *)filter:(int (^)(id item))predicate; - -- (NSDictionary *)groupBy:(id (^)(id value))keySelector; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/FunctionalUtil.m b/SignalUtilitiesKit/Utilities/FunctionalUtil.m deleted file mode 100644 index 65fe6dc5c..000000000 --- a/SignalUtilitiesKit/Utilities/FunctionalUtil.m +++ /dev/null @@ -1,98 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "FunctionalUtil.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface FUBadArgument : NSException - -+ (FUBadArgument *) new:(NSString *)reason; -+ (void)raise:(NSString *)message; - -@end - -@implementation FUBadArgument - -+ (FUBadArgument *) new:(NSString *)reason { - return [[FUBadArgument alloc] initWithName:@"Invalid Argument" reason:reason userInfo:nil]; -} -+ (void)raise:(NSString *)message { - [FUBadArgument raise:@"Invalid Argument" format:@"%@", message]; -} - -@end - -#define tskit_require(expr) \ - if (!(expr)) { \ - NSString *reason = \ - [NSString stringWithFormat:@"require %@ (in %s at line %d)", (@ #expr), __FILE__, __LINE__]; \ - OWSLogError(@"%@", reason); \ - [FUBadArgument raise:reason]; \ - }; - - -@implementation NSArray (FunctionalUtil) -- (bool)any:(int (^)(id item))predicate { - tskit_require(predicate != nil); - for (id e in self) { - if (predicate(e)) { - return true; - } - } - return false; -} -- (bool)all:(int (^)(id item))predicate { - tskit_require(predicate != nil); - for (id e in self) { - if (!predicate(e)) { - return false; - } - } - return true; -} -- (NSArray *)map:(id (^)(id item))projection { - tskit_require(projection != nil); - - NSMutableArray *r = [NSMutableArray arrayWithCapacity:self.count]; - for (id e in self) { - [r addObject:projection(e)]; - } - return r; -} -- (NSArray *)filter:(int (^)(id item))predicate { - tskit_require(predicate != nil); - - NSMutableArray *r = [NSMutableArray array]; - for (id e in self) { - if (predicate(e)) { - [r addObject:e]; - } - } - return r; -} - -- (NSDictionary *)groupBy:(id (^)(id value))keySelector { - tskit_require(keySelector != nil); - - NSMutableDictionary *result = [NSMutableDictionary dictionary]; - - for (id item in self) { - id key = keySelector(item); - - NSMutableArray *group = result[key]; - if (group == nil) { - group = [NSMutableArray array]; - result[key] = group; - } - [group addObject:item]; - } - - return result; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.h b/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.h deleted file mode 100644 index 62718ffe3..000000000 --- a/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSURLSessionTask (StatusCode) - -- (long)statusCode; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.m b/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.m deleted file mode 100644 index 212eeac55..000000000 --- a/SignalUtilitiesKit/Utilities/NSURLSessionDataTask+StatusCode.m +++ /dev/null @@ -1,18 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "NSURLSessionDataTask+StatusCode.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation NSURLSessionTask (StatusCode) - -- (long)statusCode { - NSHTTPURLResponse *response = (NSHTTPURLResponse *)self.response; - return response.statusCode; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSError.h b/SignalUtilitiesKit/Utilities/OWSError.h deleted file mode 100644 index e4772fc17..000000000 --- a/SignalUtilitiesKit/Utilities/OWSError.h +++ /dev/null @@ -1,69 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const OWSSignalServiceKitErrorDomain; - -typedef NS_ENUM(NSInteger, OWSErrorCode) { - OWSErrorCodeInvalidMethodParameters = 11, - OWSErrorCodeUnableToProcessServerResponse = 12, - OWSErrorCodeFailedToDecodeJson = 13, - OWSErrorCodeFailedToEncodeJson = 14, - OWSErrorCodeFailedToDecodeQR = 15, - OWSErrorCodePrivacyVerificationFailure = 20, - OWSErrorCodeUntrustedIdentity = 25, - OWSErrorCodeFailedToSendOutgoingMessage = 30, - OWSErrorCodeAssertionFailure = 31, - OWSErrorCodeFailedToDecryptMessage = 100, - OWSErrorCodeFailedToDecryptUDMessage = 101, - OWSErrorCodeFailedToEncryptMessage = 110, - OWSErrorCodeFailedToEncryptUDMessage = 111, - OWSErrorCodeSignalServiceFailure = 1001, - OWSErrorCodeSignalServiceRateLimited = 1010, - OWSErrorCodeUserError = 2001, - OWSErrorCodeMessageSendDisabledDueToPreKeyUpdateFailures = 777405, - OWSErrorCodeMessageSendFailedToBlockList = 777406, - OWSErrorCodeMessageSendNoValidRecipients = 777407, - OWSErrorCodeContactsUpdaterRateLimit = 777408, - OWSErrorCodeCouldNotWriteAttachmentData = 777409, - OWSErrorCodeMessageDeletedBeforeSent = 777410, - OWSErrorCodeDatabaseConversionFatalError = 777411, - OWSErrorCodeMoveFileToSharedDataContainerError = 777412, - OWSErrorCodeRegistrationMissing2FAPIN = 777413, - OWSErrorCodeDebugLogUploadFailed = 777414, - // A non-recoverable error occured while exporting a backup. - OWSErrorCodeExportBackupFailed = 777415, - // A possibly recoverable error occured while exporting a backup. - OWSErrorCodeExportBackupError = 777416, - // A non-recoverable error occured while importing a backup. - OWSErrorCodeImportBackupFailed = 777417, - // A possibly recoverable error occured while importing a backup. - OWSErrorCodeImportBackupError = 777418, - // A non-recoverable while importing or exporting a backup. - OWSErrorCodeBackupFailure = 777419, - OWSErrorCodeLocalAuthenticationError = 777420, - OWSErrorCodeMessageRequestFailed = 777421, - OWSErrorCodeMessageResponseFailed = 777422, - OWSErrorCodeInvalidMessage = 777423, - OWSErrorCodeProfileUpdateFailed = 777424, - OWSErrorCodeAvatarWriteFailed = 777425, - OWSErrorCodeAvatarUploadFailed = 777426, - OWSErrorCodeNoSessionForTransientMessage, -}; - -extern NSString *const OWSErrorRecipientIdentifierKey; - -extern NSError *OWSErrorWithCodeDescription(OWSErrorCode code, NSString *description); -extern NSError *OWSErrorMakeUntrustedIdentityError(NSString *description, NSString *recipientId); -extern NSError *OWSErrorMakeUnableToProcessServerResponseError(void); -extern NSError *OWSErrorMakeFailedToSendOutgoingMessageError(void); -extern NSError *OWSErrorMakeAssertionError(NSString *description); -extern NSError *OWSErrorMakeMessageSendDisabledDueToPreKeyUpdateFailuresError(void); -extern NSError *OWSErrorMakeMessageSendFailedDueToBlockListError(void); -extern NSError *OWSErrorMakeWriteAttachmentDataError(void); - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSError.m b/SignalUtilitiesKit/Utilities/OWSError.m deleted file mode 100644 index f8096d70e..000000000 --- a/SignalUtilitiesKit/Utilities/OWSError.m +++ /dev/null @@ -1,68 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSError.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const OWSSignalServiceKitErrorDomain = @"OWSSignalServiceKitErrorDomain"; -NSString *const OWSErrorRecipientIdentifierKey = @"OWSErrorKeyRecipientIdentifier"; - -NSError *OWSErrorWithCodeDescription(OWSErrorCode code, NSString *description) -{ - return [NSError errorWithDomain:OWSSignalServiceKitErrorDomain - code:code - userInfo:@{ NSLocalizedDescriptionKey: description }]; -} - -NSError *OWSErrorMakeUnableToProcessServerResponseError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeUnableToProcessServerResponse, - NSLocalizedString(@"ERROR_DESCRIPTION_SERVER_FAILURE", @"Generic server error")); -} - -NSError *OWSErrorMakeFailedToSendOutgoingMessageError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeFailedToSendOutgoingMessage, - NSLocalizedString(@"ERROR_DESCRIPTION_CLIENT_SENDING_FAILURE", @"Generic notice when message failed to send.")); -} - -NSError *OWSErrorMakeAssertionError(NSString *description) -{ - OWSCFailDebug(@"Assertion failed: %@", description); - return OWSErrorWithCodeDescription(OWSErrorCodeAssertionFailure, - NSLocalizedString(@"ERROR_DESCRIPTION_UNKNOWN_ERROR", @"Worst case generic error message")); -} - -NSError *OWSErrorMakeUntrustedIdentityError(NSString *description, NSString *recipientId) -{ - return [NSError - errorWithDomain:OWSSignalServiceKitErrorDomain - code:OWSErrorCodeUntrustedIdentity - userInfo:@{ NSLocalizedDescriptionKey : description, OWSErrorRecipientIdentifierKey : recipientId }]; -} - -NSError *OWSErrorMakeMessageSendDisabledDueToPreKeyUpdateFailuresError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeMessageSendDisabledDueToPreKeyUpdateFailures, - NSLocalizedString(@"ERROR_DESCRIPTION_MESSAGE_SEND_DISABLED_PREKEY_UPDATE_FAILURES", - @"Error message indicating that message send is disabled due to prekey update failures")); -} - -NSError *OWSErrorMakeMessageSendFailedDueToBlockListError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeMessageSendFailedToBlockList, - NSLocalizedString(@"ERROR_DESCRIPTION_MESSAGE_SEND_FAILED_DUE_TO_BLOCK_LIST", - @"Error message indicating that message send failed due to block list")); -} - -NSError *OWSErrorMakeWriteAttachmentDataError() -{ - return OWSErrorWithCodeDescription(OWSErrorCodeCouldNotWriteAttachmentData, - NSLocalizedString(@"ERROR_DESCRIPTION_MESSAGE_SEND_FAILED_DUE_TO_FAILED_ATTACHMENT_WRITE", - @"Error message indicating that message send failed due to failed attachment write")); -} - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSOperation.h b/SignalUtilitiesKit/Utilities/OWSOperation.h deleted file mode 100644 index ebeeaa53f..000000000 --- a/SignalUtilitiesKit/Utilities/OWSOperation.h +++ /dev/null @@ -1,88 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSInteger, OWSOperationState) { - OWSOperationStateNew, - OWSOperationStateExecuting, - OWSOperationStateFinished -}; - -// A base class for implementing retryable operations. -// To utilize the retryable behavior: -// Set remainingRetries to something greater than 0, and when you're reporting an error, -// set `error.isRetryable = YES`. -// If the failure is one that will not succeed upon retry, set `error.isFatal = YES`. -// -// isRetryable and isFatal are opposites but not redundant. -// -// If a group message send fails, the send will be retried if any of the errors were retryable UNLESS -// any of the errors were fatal. Fatal errors trump retryable errors. -@interface OWSOperation : NSOperation - -@property (readonly, nullable) NSError *failingError; - -// Defaults to 0, set to greater than 0 in init if you'd like the operation to be retryable. -@property NSUInteger remainingRetries; - -#pragma mark - Mandatory Subclass Overrides - -// Called every retry, this is where the bulk of the operation's work should go. -- (void)run; - -#pragma mark - Optional Subclass Overrides - -// Called one time only -- (nullable NSError *)checkForPreconditionError; - -// Called at most one time. -- (void)didSucceed; - -// Called at most one time. -- (void)didCancel; - -// Called zero or more times, retry may be possible -- (void)didReportError:(NSError *)error; - -// Called at most one time, once retry is no longer possible. -- (void)didFailWithError:(NSError *)error NS_SWIFT_NAME(didFail(error:)); - -// How long to wait before retry, if possible -- (NSTimeInterval)retryInterval; - -#pragma mark - Success/Error - Do Not Override - -// Runs now if a retry timer has been set by a previous failure, -// otherwise assumes we're currently running and does nothing. -- (void)runAnyQueuedRetry; - -// Report that the operation completed successfully. -// -// Each invocation of `run` must make exactly one call to one of: `reportSuccess`, `reportCancelled`, or `reportError:` -- (void)reportSuccess; - -// Call this when you abort before completion due to being cancelled. -// -// Each invocation of `run` must make exactly one call to one of: `reportSuccess`, `reportCancelled`, or `reportError:` -- (void)reportCancelled; - -// Report that the operation failed to complete due to an error. -// -// Each invocation of `run` must make exactly one call to one of: `reportSuccess`, `reportCancelled`, or `reportError:` -// You must ensure that `run` cannot succeed after calling `reportError`, e.g. generally you'll write something like -// this: -// -// [self reportError:someError]; -// return; -// -// If the error is terminal, and you want to avoid retry, report an error with `error.isFatal = YES` otherwise the -// operation will retry if possible. -- (void)reportError:(NSError *)error; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSOperation.m b/SignalUtilitiesKit/Utilities/OWSOperation.m deleted file mode 100644 index 47e511990..000000000 --- a/SignalUtilitiesKit/Utilities/OWSOperation.m +++ /dev/null @@ -1,253 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSOperation.h" -#import "OWSError.h" -#import -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSString *const OWSOperationKeyIsExecuting = @"isExecuting"; -NSString *const OWSOperationKeyIsFinished = @"isFinished"; - -@interface OWSOperation () - -@property (nullable) NSError *failingError; -@property (atomic) OWSOperationState operationState; -@property (nonatomic) OWSBackgroundTask *backgroundTask; - -// This property should only be accessed on the main queue. -@property (nonatomic) NSTimer *_Nullable retryTimer; - -@end - -@implementation OWSOperation - -- (instancetype)init -{ - self = [super init]; - if (!self) { - return self; - } - - _operationState = OWSOperationStateNew; - _backgroundTask = [OWSBackgroundTask backgroundTaskWithLabel:self.logTag]; - - // Operations are not retryable by default. - _remainingRetries = 0; - - return self; -} - -- (void)dealloc -{ - OWSLogDebug(@"in dealloc"); -} - -#pragma mark - Subclass Overrides - -// Called one time only -- (nullable NSError *)checkForPreconditionError -{ - // OWSOperation have a notion of failure, which is inferred by the presence of a `failingError`. - // - // By default, any failing dependency cascades that failure to it's dependent. - // If you'd like different behavior, override this method (`checkForPreconditionError`) without calling `super`. - for (NSOperation *dependency in self.dependencies) { - if (![dependency isKindOfClass:[OWSOperation class]]) { - // Native operations, like NSOperation and NSBlockOperation have no notion of "failure". - // So there's no `failingError` to cascade. - continue; - } - - OWSOperation *dependentOperation = (OWSOperation *)dependency; - - // Don't proceed if dependency failed - surface the dependency's error. - NSError *_Nullable dependencyError = dependentOperation.failingError; - if (dependencyError != nil) { - return dependencyError; - } - } - - return nil; -} - -// Called every retry, this is where the bulk of the operation's work should go. -- (void)run -{ - OWSAbstractMethod(); -} - -// Called at most one time. -- (void)didSucceed -{ - // no-op - // Override in subclass if necessary -} - -// Called at most one time. -- (void)didCancel -{ - // no-op - // Override in subclass if necessary -} - -// Called zero or more times, retry may be possible -- (void)didReportError:(NSError *)error -{ - // no-op - // Override in subclass if necessary -} - -// Called at most one time, once retry is no longer possible. -- (void)didFailWithError:(NSError *)error -{ - // no-op - // Override in subclass if necessary -} - -#pragma mark - NSOperation overrides - -// Do not override this method in a subclass instead, override `run` -- (void)main -{ - OWSLogDebug(@"started."); - NSError *_Nullable preconditionError = [self checkForPreconditionError]; - if (preconditionError) { - [self failOperationWithError:preconditionError]; - return; - } - - if (self.isCancelled) { - [self reportCancelled]; - return; - } - - [self run]; -} - -- (void)runAnyQueuedRetry -{ - dispatch_async(dispatch_get_main_queue(), ^{ - NSTimer *_Nullable retryTimer = self.retryTimer; - self.retryTimer = nil; - [retryTimer invalidate]; - - if (retryTimer != nil) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [self run]; - }); - } else { - OWSLogVerbose(@"not re-running since operation is already running."); - } - }); -} - -#pragma mark - Public Methods - -// These methods are not intended to be subclassed -- (void)reportSuccess -{ - OWSLogDebug(@"succeeded."); - [self didSucceed]; - [self markAsComplete]; -} - -// These methods are not intended to be subclassed -- (void)reportCancelled -{ - OWSLogDebug(@"cancelled."); - [self didCancel]; - [self markAsComplete]; -} - -- (void)reportError:(NSError *)error -{ - [self didReportError:error]; - - if (self.remainingRetries == 0) { - [self failOperationWithError:error]; - return; - } - - self.remainingRetries--; - - dispatch_async(dispatch_get_main_queue(), ^{ - OWSAssertDebug(self.retryTimer == nil); - [self.retryTimer invalidate]; - - // The `scheduledTimerWith*` methods add the timer to the current thread's RunLoop. - // Since Operations typically run on a background thread, that would mean the background - // thread's RunLoop. However, the OS can spin down background threads if there's no work - // being done, so we run the risk of the timer's RunLoop being deallocated before it's - // fired. - // - // To ensure the timer's thread sticks around, we schedule it while on the main RunLoop. - self.retryTimer = [NSTimer weakScheduledTimerWithTimeInterval:self.retryInterval - target:self - selector:@selector(runAnyQueuedRetry) - userInfo:nil - repeats:NO]; - }); -} - -// Override in subclass if you want something more sophisticated, e.g. exponential backoff -- (NSTimeInterval)retryInterval -{ - return 0.1; -} - -#pragma mark - Life Cycle - -- (void)failOperationWithError:(NSError *)error -{ - OWSLogDebug(@"failed terminally."); - self.failingError = error; - - [self didFailWithError:error]; - [self markAsComplete]; -} - -- (BOOL)isExecuting -{ - return self.operationState == OWSOperationStateExecuting; -} - -- (BOOL)isFinished -{ - return self.operationState == OWSOperationStateFinished; -} - -- (void)start -{ - [self willChangeValueForKey:OWSOperationKeyIsExecuting]; - self.operationState = OWSOperationStateExecuting; - [self didChangeValueForKey:OWSOperationKeyIsExecuting]; - - [self main]; -} - -- (void)markAsComplete -{ - [self willChangeValueForKey:OWSOperationKeyIsExecuting]; - [self willChangeValueForKey:OWSOperationKeyIsFinished]; - - // Ensure we call the success or failure handler exactly once. - @synchronized(self) - { - OWSAssertDebug(self.operationState != OWSOperationStateFinished); - - self.operationState = OWSOperationStateFinished; - } - - [self didChangeValueForKey:OWSOperationKeyIsExecuting]; - [self didChangeValueForKey:OWSOperationKeyIsFinished]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/ReachabilityManager.swift b/SignalUtilitiesKit/Utilities/ReachabilityManager.swift index c9b884db8..6f683bcb7 100644 --- a/SignalUtilitiesKit/Utilities/ReachabilityManager.swift +++ b/SignalUtilitiesKit/Utilities/ReachabilityManager.swift @@ -3,6 +3,7 @@ import Foundation import Reachability import SignalCoreKit +import SessionMessagingKit /// **Warning:** The simulator doesn't detect reachability correctly so if you are seeing odd/incorrect reachability states double /// check on an actual device before trying to replace this implementation diff --git a/SignalUtilitiesKit/Utilities/SignalIOS.pb.swift b/SignalUtilitiesKit/Utilities/SignalIOS.pb.swift deleted file mode 100644 index 588c729ed..000000000 --- a/SignalUtilitiesKit/Utilities/SignalIOS.pb.swift +++ /dev/null @@ -1,318 +0,0 @@ -// DO NOT EDIT. -// swift-format-ignore-file -// -// Generated by the Swift generator plugin for the protocol buffer compiler. -// Source: SignalIOS.proto -// -// For information on using the generated types, please see the documentation: -// https://github.com/apple/swift-protobuf/ - -//* -// Copyright (C) 2014-2016 Open Whisper Systems -// -// Licensed according to the LICENSE file in this repository. - -/// iOS - since we use a modern proto-compiler, we must specify -/// the legacy proto format. - -import Foundation -import SwiftProtobuf - -// If the compiler emits an error on this type, it is because this file -// was generated by a version of the `protoc` Swift plug-in that is -// incompatible with the version of SwiftProtobuf to which you are linking. -// Please ensure that you are building against the same version of the API -// that was used to generate this file. -fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { - struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} - typealias Version = _2 -} - -struct IOSProtos_BackupSnapshot { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - var entity: [IOSProtos_BackupSnapshot.BackupEntity] = [] - - var unknownFields = SwiftProtobuf.UnknownStorage() - - struct BackupEntity { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - /// @required - var type: IOSProtos_BackupSnapshot.BackupEntity.TypeEnum { - get {return _type ?? .unknown} - set {_type = newValue} - } - /// Returns true if `type` has been explicitly set. - var hasType: Bool {return self._type != nil} - /// Clears the value of `type`. Subsequent reads from it will return its default value. - mutating func clearType() {self._type = nil} - - /// @required - var entityData: Data { - get {return _entityData ?? SwiftProtobuf.Internal.emptyData} - set {_entityData = newValue} - } - /// Returns true if `entityData` has been explicitly set. - var hasEntityData: Bool {return self._entityData != nil} - /// Clears the value of `entityData`. Subsequent reads from it will return its default value. - mutating func clearEntityData() {self._entityData = nil} - - /// @required - var collection: String { - get {return _collection ?? String()} - set {_collection = newValue} - } - /// Returns true if `collection` has been explicitly set. - var hasCollection: Bool {return self._collection != nil} - /// Clears the value of `collection`. Subsequent reads from it will return its default value. - mutating func clearCollection() {self._collection = nil} - - /// @required - var key: String { - get {return _key ?? String()} - set {_key = newValue} - } - /// Returns true if `key` has been explicitly set. - var hasKey: Bool {return self._key != nil} - /// Clears the value of `key`. Subsequent reads from it will return its default value. - mutating func clearKey() {self._key = nil} - - var unknownFields = SwiftProtobuf.UnknownStorage() - - enum TypeEnum: SwiftProtobuf.Enum { - typealias RawValue = Int - case unknown // = 0 - case migration // = 1 - case thread // = 2 - case interaction // = 3 - case attachment // = 4 - case misc // = 5 - - init() { - self = .unknown - } - - init?(rawValue: Int) { - switch rawValue { - case 0: self = .unknown - case 1: self = .migration - case 2: self = .thread - case 3: self = .interaction - case 4: self = .attachment - case 5: self = .misc - default: return nil - } - } - - var rawValue: Int { - switch self { - case .unknown: return 0 - case .migration: return 1 - case .thread: return 2 - case .interaction: return 3 - case .attachment: return 4 - case .misc: return 5 - } - } - - } - - init() {} - - fileprivate var _type: IOSProtos_BackupSnapshot.BackupEntity.TypeEnum? = nil - fileprivate var _entityData: Data? = nil - fileprivate var _collection: String? = nil - fileprivate var _key: String? = nil - } - - init() {} -} - -#if swift(>=4.2) - -extension IOSProtos_BackupSnapshot.BackupEntity.TypeEnum: CaseIterable { - // Support synthesized by the compiler. -} - -#endif // swift(>=4.2) - -struct IOSProtos_DeviceName { - // SwiftProtobuf.Message conformance is added in an extension below. See the - // `Message` and `Message+*Additions` files in the SwiftProtobuf library for - // methods supported on all messages. - - /// @required - var ephemeralPublic: Data { - get {return _ephemeralPublic ?? SwiftProtobuf.Internal.emptyData} - set {_ephemeralPublic = newValue} - } - /// Returns true if `ephemeralPublic` has been explicitly set. - var hasEphemeralPublic: Bool {return self._ephemeralPublic != nil} - /// Clears the value of `ephemeralPublic`. Subsequent reads from it will return its default value. - mutating func clearEphemeralPublic() {self._ephemeralPublic = nil} - - /// @required - var syntheticIv: Data { - get {return _syntheticIv ?? SwiftProtobuf.Internal.emptyData} - set {_syntheticIv = newValue} - } - /// Returns true if `syntheticIv` has been explicitly set. - var hasSyntheticIv: Bool {return self._syntheticIv != nil} - /// Clears the value of `syntheticIv`. Subsequent reads from it will return its default value. - mutating func clearSyntheticIv() {self._syntheticIv = nil} - - /// @required - var ciphertext: Data { - get {return _ciphertext ?? SwiftProtobuf.Internal.emptyData} - set {_ciphertext = newValue} - } - /// Returns true if `ciphertext` has been explicitly set. - var hasCiphertext: Bool {return self._ciphertext != nil} - /// Clears the value of `ciphertext`. Subsequent reads from it will return its default value. - mutating func clearCiphertext() {self._ciphertext = nil} - - var unknownFields = SwiftProtobuf.UnknownStorage() - - init() {} - - fileprivate var _ephemeralPublic: Data? = nil - fileprivate var _syntheticIv: Data? = nil - fileprivate var _ciphertext: Data? = nil -} - -// MARK: - Code below here is support for the SwiftProtobuf runtime. - -fileprivate let _protobuf_package = "IOSProtos" - -extension IOSProtos_BackupSnapshot: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".BackupSnapshot" - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "entity"), - ] - - mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeRepeatedMessageField(value: &self.entity) - default: break - } - } - } - - func traverse(visitor: inout V) throws { - if !self.entity.isEmpty { - try visitor.visitRepeatedMessageField(value: self.entity, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } - - static func ==(lhs: IOSProtos_BackupSnapshot, rhs: IOSProtos_BackupSnapshot) -> Bool { - if lhs.entity != rhs.entity {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension IOSProtos_BackupSnapshot.BackupEntity: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = IOSProtos_BackupSnapshot.protoMessageName + ".BackupEntity" - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "type"), - 2: .same(proto: "entityData"), - 3: .same(proto: "collection"), - 4: .same(proto: "key"), - ] - - mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularEnumField(value: &self._type) - case 2: try decoder.decodeSingularBytesField(value: &self._entityData) - case 3: try decoder.decodeSingularStringField(value: &self._collection) - case 4: try decoder.decodeSingularStringField(value: &self._key) - default: break - } - } - } - - func traverse(visitor: inout V) throws { - if let v = self._type { - try visitor.visitSingularEnumField(value: v, fieldNumber: 1) - } - if let v = self._entityData { - try visitor.visitSingularBytesField(value: v, fieldNumber: 2) - } - if let v = self._collection { - try visitor.visitSingularStringField(value: v, fieldNumber: 3) - } - if let v = self._key { - try visitor.visitSingularStringField(value: v, fieldNumber: 4) - } - try unknownFields.traverse(visitor: &visitor) - } - - static func ==(lhs: IOSProtos_BackupSnapshot.BackupEntity, rhs: IOSProtos_BackupSnapshot.BackupEntity) -> Bool { - if lhs._type != rhs._type {return false} - if lhs._entityData != rhs._entityData {return false} - if lhs._collection != rhs._collection {return false} - if lhs._key != rhs._key {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -extension IOSProtos_BackupSnapshot.BackupEntity.TypeEnum: SwiftProtobuf._ProtoNameProviding { - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 0: .same(proto: "UNKNOWN"), - 1: .same(proto: "MIGRATION"), - 2: .same(proto: "THREAD"), - 3: .same(proto: "INTERACTION"), - 4: .same(proto: "ATTACHMENT"), - 5: .same(proto: "MISC"), - ] -} - -extension IOSProtos_DeviceName: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".DeviceName" - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "ephemeralPublic"), - 2: .same(proto: "syntheticIv"), - 3: .same(proto: "ciphertext"), - ] - - mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - switch fieldNumber { - case 1: try decoder.decodeSingularBytesField(value: &self._ephemeralPublic) - case 2: try decoder.decodeSingularBytesField(value: &self._syntheticIv) - case 3: try decoder.decodeSingularBytesField(value: &self._ciphertext) - default: break - } - } - } - - func traverse(visitor: inout V) throws { - if let v = self._ephemeralPublic { - try visitor.visitSingularBytesField(value: v, fieldNumber: 1) - } - if let v = self._syntheticIv { - try visitor.visitSingularBytesField(value: v, fieldNumber: 2) - } - if let v = self._ciphertext { - try visitor.visitSingularBytesField(value: v, fieldNumber: 3) - } - try unknownFields.traverse(visitor: &visitor) - } - - static func ==(lhs: IOSProtos_DeviceName, rhs: IOSProtos_DeviceName) -> Bool { - if lhs._ephemeralPublic != rhs._ephemeralPublic {return false} - if lhs._syntheticIv != rhs._syntheticIv {return false} - if lhs._ciphertext != rhs._ciphertext {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} diff --git a/SignalUtilitiesKit/Utilities/SignalIOSProto.swift b/SignalUtilitiesKit/Utilities/SignalIOSProto.swift deleted file mode 100644 index 5761fbda7..000000000 --- a/SignalUtilitiesKit/Utilities/SignalIOSProto.swift +++ /dev/null @@ -1,409 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation - -// WARNING: This code is generated. Only edit within the markers. - -public enum SignalIOSProtoError: Error { - case invalidProtobuf(description: String) -} - -// MARK: - SignalIOSProtoBackupSnapshotBackupEntity - -@objc public class SignalIOSProtoBackupSnapshotBackupEntity: NSObject { - - // MARK: - SignalIOSProtoBackupSnapshotBackupEntityType - - @objc public enum SignalIOSProtoBackupSnapshotBackupEntityType: Int32 { - case unknown = 0 - case migration = 1 - case thread = 2 - case interaction = 3 - case attachment = 4 - case misc = 5 - } - - private class func SignalIOSProtoBackupSnapshotBackupEntityTypeWrap(_ value: IOSProtos_BackupSnapshot.BackupEntity.TypeEnum) -> SignalIOSProtoBackupSnapshotBackupEntityType { - switch value { - case .unknown: return .unknown - case .migration: return .migration - case .thread: return .thread - case .interaction: return .interaction - case .attachment: return .attachment - case .misc: return .misc - } - } - - private class func SignalIOSProtoBackupSnapshotBackupEntityTypeUnwrap(_ value: SignalIOSProtoBackupSnapshotBackupEntityType) -> IOSProtos_BackupSnapshot.BackupEntity.TypeEnum { - switch value { - case .unknown: return .unknown - case .migration: return .migration - case .thread: return .thread - case .interaction: return .interaction - case .attachment: return .attachment - case .misc: return .misc - } - } - - // MARK: - SignalIOSProtoBackupSnapshotBackupEntityBuilder - - @objc public class func builder(type: SignalIOSProtoBackupSnapshotBackupEntityType, entityData: Data, collection: String, key: String) -> SignalIOSProtoBackupSnapshotBackupEntityBuilder { - return SignalIOSProtoBackupSnapshotBackupEntityBuilder(type: type, entityData: entityData, collection: collection, key: key) - } - - // asBuilder() constructs a builder that reflects the proto's contents. - @objc public func asBuilder() -> SignalIOSProtoBackupSnapshotBackupEntityBuilder { - let builder = SignalIOSProtoBackupSnapshotBackupEntityBuilder(type: type, entityData: entityData, collection: collection, key: key) - return builder - } - - @objc public class SignalIOSProtoBackupSnapshotBackupEntityBuilder: NSObject { - - private var proto = IOSProtos_BackupSnapshot.BackupEntity() - - @objc fileprivate override init() {} - - @objc fileprivate init(type: SignalIOSProtoBackupSnapshotBackupEntityType, entityData: Data, collection: String, key: String) { - super.init() - - setType(type) - setEntityData(entityData) - setCollection(collection) - setKey(key) - } - - @objc public func setType(_ valueParam: SignalIOSProtoBackupSnapshotBackupEntityType) { - proto.type = SignalIOSProtoBackupSnapshotBackupEntityTypeUnwrap(valueParam) - } - - @objc public func setEntityData(_ valueParam: Data) { - proto.entityData = valueParam - } - - @objc public func setCollection(_ valueParam: String) { - proto.collection = valueParam - } - - @objc public func setKey(_ valueParam: String) { - proto.key = valueParam - } - - @objc public func build() throws -> SignalIOSProtoBackupSnapshotBackupEntity { - return try SignalIOSProtoBackupSnapshotBackupEntity.parseProto(proto) - } - - @objc public func buildSerializedData() throws -> Data { - return try SignalIOSProtoBackupSnapshotBackupEntity.parseProto(proto).serializedData() - } - } - - fileprivate let proto: IOSProtos_BackupSnapshot.BackupEntity - - @objc public let type: SignalIOSProtoBackupSnapshotBackupEntityType - - @objc public let entityData: Data - - @objc public let collection: String - - @objc public let key: String - - private init(proto: IOSProtos_BackupSnapshot.BackupEntity, - type: SignalIOSProtoBackupSnapshotBackupEntityType, - entityData: Data, - collection: String, - key: String) { - self.proto = proto - self.type = type - self.entityData = entityData - self.collection = collection - self.key = key - } - - @objc - public func serializedData() throws -> Data { - return try self.proto.serializedData() - } - - @objc public class func parseData(_ serializedData: Data) throws -> SignalIOSProtoBackupSnapshotBackupEntity { - let proto = try IOSProtos_BackupSnapshot.BackupEntity(serializedData: serializedData) - return try parseProto(proto) - } - - fileprivate class func parseProto(_ proto: IOSProtos_BackupSnapshot.BackupEntity) throws -> SignalIOSProtoBackupSnapshotBackupEntity { - guard proto.hasType else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: type") - } - let type = SignalIOSProtoBackupSnapshotBackupEntityTypeWrap(proto.type) - - guard proto.hasEntityData else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: entityData") - } - let entityData = proto.entityData - - guard proto.hasCollection else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: collection") - } - let collection = proto.collection - - guard proto.hasKey else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: key") - } - let key = proto.key - - // MARK: - Begin Validation Logic for SignalIOSProtoBackupSnapshotBackupEntity - - - // MARK: - End Validation Logic for SignalIOSProtoBackupSnapshotBackupEntity - - - let result = SignalIOSProtoBackupSnapshotBackupEntity(proto: proto, - type: type, - entityData: entityData, - collection: collection, - key: key) - return result - } - - @objc public override var debugDescription: String { - return "\(proto)" - } -} - -#if DEBUG - -extension SignalIOSProtoBackupSnapshotBackupEntity { - @objc public func serializedDataIgnoringErrors() -> Data? { - return try! self.serializedData() - } -} - -extension SignalIOSProtoBackupSnapshotBackupEntity.SignalIOSProtoBackupSnapshotBackupEntityBuilder { - @objc public func buildIgnoringErrors() -> SignalIOSProtoBackupSnapshotBackupEntity? { - return try! self.build() - } -} - -#endif - -// MARK: - SignalIOSProtoBackupSnapshot - -@objc public class SignalIOSProtoBackupSnapshot: NSObject { - - // MARK: - SignalIOSProtoBackupSnapshotBuilder - - @objc public class func builder() -> SignalIOSProtoBackupSnapshotBuilder { - return SignalIOSProtoBackupSnapshotBuilder() - } - - // asBuilder() constructs a builder that reflects the proto's contents. - @objc public func asBuilder() -> SignalIOSProtoBackupSnapshotBuilder { - let builder = SignalIOSProtoBackupSnapshotBuilder() - builder.setEntity(entity) - return builder - } - - @objc public class SignalIOSProtoBackupSnapshotBuilder: NSObject { - - private var proto = IOSProtos_BackupSnapshot() - - @objc fileprivate override init() {} - - @objc public func addEntity(_ valueParam: SignalIOSProtoBackupSnapshotBackupEntity) { - var items = proto.entity - items.append(valueParam.proto) - proto.entity = items - } - - @objc public func setEntity(_ wrappedItems: [SignalIOSProtoBackupSnapshotBackupEntity]) { - proto.entity = wrappedItems.map { $0.proto } - } - - @objc public func build() throws -> SignalIOSProtoBackupSnapshot { - return try SignalIOSProtoBackupSnapshot.parseProto(proto) - } - - @objc public func buildSerializedData() throws -> Data { - return try SignalIOSProtoBackupSnapshot.parseProto(proto).serializedData() - } - } - - fileprivate let proto: IOSProtos_BackupSnapshot - - @objc public let entity: [SignalIOSProtoBackupSnapshotBackupEntity] - - private init(proto: IOSProtos_BackupSnapshot, - entity: [SignalIOSProtoBackupSnapshotBackupEntity]) { - self.proto = proto - self.entity = entity - } - - @objc - public func serializedData() throws -> Data { - return try self.proto.serializedData() - } - - @objc public class func parseData(_ serializedData: Data) throws -> SignalIOSProtoBackupSnapshot { - let proto = try IOSProtos_BackupSnapshot(serializedData: serializedData) - return try parseProto(proto) - } - - fileprivate class func parseProto(_ proto: IOSProtos_BackupSnapshot) throws -> SignalIOSProtoBackupSnapshot { - var entity: [SignalIOSProtoBackupSnapshotBackupEntity] = [] - entity = try proto.entity.map { try SignalIOSProtoBackupSnapshotBackupEntity.parseProto($0) } - - // MARK: - Begin Validation Logic for SignalIOSProtoBackupSnapshot - - - // MARK: - End Validation Logic for SignalIOSProtoBackupSnapshot - - - let result = SignalIOSProtoBackupSnapshot(proto: proto, - entity: entity) - return result - } - - @objc public override var debugDescription: String { - return "\(proto)" - } -} - -#if DEBUG - -extension SignalIOSProtoBackupSnapshot { - @objc public func serializedDataIgnoringErrors() -> Data? { - return try! self.serializedData() - } -} - -extension SignalIOSProtoBackupSnapshot.SignalIOSProtoBackupSnapshotBuilder { - @objc public func buildIgnoringErrors() -> SignalIOSProtoBackupSnapshot? { - return try! self.build() - } -} - -#endif - -// MARK: - SignalIOSProtoDeviceName - -@objc public class SignalIOSProtoDeviceName: NSObject { - - // MARK: - SignalIOSProtoDeviceNameBuilder - - @objc public class func builder(ephemeralPublic: Data, syntheticIv: Data, ciphertext: Data) -> SignalIOSProtoDeviceNameBuilder { - return SignalIOSProtoDeviceNameBuilder(ephemeralPublic: ephemeralPublic, syntheticIv: syntheticIv, ciphertext: ciphertext) - } - - // asBuilder() constructs a builder that reflects the proto's contents. - @objc public func asBuilder() -> SignalIOSProtoDeviceNameBuilder { - let builder = SignalIOSProtoDeviceNameBuilder(ephemeralPublic: ephemeralPublic, syntheticIv: syntheticIv, ciphertext: ciphertext) - return builder - } - - @objc public class SignalIOSProtoDeviceNameBuilder: NSObject { - - private var proto = IOSProtos_DeviceName() - - @objc fileprivate override init() {} - - @objc fileprivate init(ephemeralPublic: Data, syntheticIv: Data, ciphertext: Data) { - super.init() - - setEphemeralPublic(ephemeralPublic) - setSyntheticIv(syntheticIv) - setCiphertext(ciphertext) - } - - @objc public func setEphemeralPublic(_ valueParam: Data) { - proto.ephemeralPublic = valueParam - } - - @objc public func setSyntheticIv(_ valueParam: Data) { - proto.syntheticIv = valueParam - } - - @objc public func setCiphertext(_ valueParam: Data) { - proto.ciphertext = valueParam - } - - @objc public func build() throws -> SignalIOSProtoDeviceName { - return try SignalIOSProtoDeviceName.parseProto(proto) - } - - @objc public func buildSerializedData() throws -> Data { - return try SignalIOSProtoDeviceName.parseProto(proto).serializedData() - } - } - - fileprivate let proto: IOSProtos_DeviceName - - @objc public let ephemeralPublic: Data - - @objc public let syntheticIv: Data - - @objc public let ciphertext: Data - - private init(proto: IOSProtos_DeviceName, - ephemeralPublic: Data, - syntheticIv: Data, - ciphertext: Data) { - self.proto = proto - self.ephemeralPublic = ephemeralPublic - self.syntheticIv = syntheticIv - self.ciphertext = ciphertext - } - - @objc - public func serializedData() throws -> Data { - return try self.proto.serializedData() - } - - @objc public class func parseData(_ serializedData: Data) throws -> SignalIOSProtoDeviceName { - let proto = try IOSProtos_DeviceName(serializedData: serializedData) - return try parseProto(proto) - } - - fileprivate class func parseProto(_ proto: IOSProtos_DeviceName) throws -> SignalIOSProtoDeviceName { - guard proto.hasEphemeralPublic else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: ephemeralPublic") - } - let ephemeralPublic = proto.ephemeralPublic - - guard proto.hasSyntheticIv else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: syntheticIv") - } - let syntheticIv = proto.syntheticIv - - guard proto.hasCiphertext else { - throw SignalIOSProtoError.invalidProtobuf(description: "\(logTag) missing required field: ciphertext") - } - let ciphertext = proto.ciphertext - - // MARK: - Begin Validation Logic for SignalIOSProtoDeviceName - - - // MARK: - End Validation Logic for SignalIOSProtoDeviceName - - - let result = SignalIOSProtoDeviceName(proto: proto, - ephemeralPublic: ephemeralPublic, - syntheticIv: syntheticIv, - ciphertext: ciphertext) - return result - } - - @objc public override var debugDescription: String { - return "\(proto)" - } -} - -#if DEBUG - -extension SignalIOSProtoDeviceName { - @objc public func serializedDataIgnoringErrors() -> Data? { - return try! self.serializedData() - } -} - -extension SignalIOSProtoDeviceName.SignalIOSProtoDeviceNameBuilder { - @objc public func buildIgnoringErrors() -> SignalIOSProtoDeviceName? { - return try! self.build() - } -} - -#endif diff --git a/SignalUtilitiesKit/Utilities/SwiftSingletons.swift b/SignalUtilitiesKit/Utilities/SwiftSingletons.swift index 5af0a3177..40bee1b66 100644 --- a/SignalUtilitiesKit/Utilities/SwiftSingletons.swift +++ b/SignalUtilitiesKit/Utilities/SwiftSingletons.swift @@ -2,6 +2,7 @@ import Foundation import SignalCoreKit +import SessionUtilitiesKit public class SwiftSingletons: NSObject { public static let shared = SwiftSingletons() diff --git a/SignalUtilitiesKit/Utilities/TSConstants.h b/SignalUtilitiesKit/Utilities/TSConstants.h deleted file mode 100644 index 3d8542585..000000000 --- a/SignalUtilitiesKit/Utilities/TSConstants.h +++ /dev/null @@ -1,78 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -#ifndef TextSecureKit_Constants_h -#define TextSecureKit_Constants_h - -extern const NSUInteger kOversizeTextMessageSizeThreshold; - -typedef NS_ENUM(NSInteger, TSWhisperMessageType) { - TSUnknownMessageType = 0, - TSEncryptedWhisperMessageType = 1, - TSIgnoreOnIOSWhisperMessageType = 2, // on droid this is the prekey bundle message irrelevant for us - TSPreKeyWhisperMessageType = 3, - TSUnencryptedWhisperMessageType = 4, - TSUnidentifiedSenderMessageType = 6, - TSClosedGroupCiphertextMessageType = 7, - TSFallbackMessageType = 101 -}; - -#pragma mark Server Address - -#define textSecureHTTPTimeOut 10 - -#define kLegalTermsUrlString @"https://getsession.org/privacy-policy/" - -//#ifndef DEBUG - -// Production -#define textSecureWebSocketAPI @"wss://textsecure-service.whispersystems.org/v1/websocket/" -#define textSecureCDNServerURL @"https://cdn.signal.org" -// Use same reflector for service and CDN -#define textSecureServiceReflectorHost @"europe-west1-signal-cdn-reflector.cloudfunctions.net" -#define textSecureCDNReflectorHost @"europe-west1-signal-cdn-reflector.cloudfunctions.net" -#define contactDiscoveryURL @"https://api.directory.signal.org" -#define kUDTrustRoot @"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF" -#define USING_PRODUCTION_SERVICE - -//#else - -// Staging -//#define textSecureWebSocketAPI @"wss://textsecure-service-staging.whispersystems.org/v1/websocket/" -//#define textSecureServerURL @"https://textsecure-service-staging.whispersystems.org/" -//#define textSecureCDNServerURL @"https://cdn-staging.signal.org" -//#define textSecureServiceReflectorHost @"meek-signal-service-staging.appspot.com"; -//#define textSecureCDNReflectorHost @"meek-signal-cdn-staging.appspot.com"; -//#define contactDiscoveryURL @"https://api-staging.directory.signal.org" -//#define kUDTrustRoot @"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx" - -//#endif - -BOOL IsUsingProductionService(void); - -#define textSecureAccountsAPI @"v1/accounts" -#define textSecureAttributesAPI @"/attributes/" - -#define textSecureMessagesAPI @"v1/messages/" -#define textSecureKeysAPI @"v2/keys" -#define textSecureSignedKeysAPI @"v2/keys/signed" -#define textSecureDirectoryAPI @"v1/directory" -#define textSecureAttachmentsAPI @"v1/attachments" -#define textSecureDeviceProvisioningCodeAPI @"v1/devices/provisioning/code" -#define textSecureDeviceProvisioningAPIFormat @"v1/provisioning/%@" -#define textSecureDevicesAPIFormat @"v1/devices/%@" -#define textSecureProfileAPIFormat @"v1/profile/%@" -#define textSecureSetProfileNameAPIFormat @"v1/profile/name/%@" -#define textSecureProfileAvatarFormAPI @"v1/profile/form/avatar" -#define textSecure2FAAPI @"/v1/accounts/pin" - -#define SignalApplicationGroup @"group.com.loki-project.loki-messenger" - -#endif - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/TSConstants.m b/SignalUtilitiesKit/Utilities/TSConstants.m deleted file mode 100644 index fbd6607e9..000000000 --- a/SignalUtilitiesKit/Utilities/TSConstants.m +++ /dev/null @@ -1,20 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSConstants.h" - -NS_ASSUME_NONNULL_BEGIN - -const NSUInteger kOversizeTextMessageSizeThreshold = 2 * 1024; - -BOOL IsUsingProductionService() -{ -#ifdef USING_PRODUCTION_SERVICE - return YES; -#else - return NO; -#endif -} - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index de1a7c5cd..b158c207c 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -3,6 +3,7 @@ import Foundation import SessionUIKit import SignalCoreKit +import SessionUtilitiesKit public extension UIEdgeInsets { init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { diff --git a/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift b/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift index 6791a15e1..d2904ab77 100644 --- a/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SessionUtilitiesKit public extension UIViewController { func findFrontmostViewController(ignoringAlerts: Bool) -> UIViewController { From c63a9d399493cfe9d61139865015b9e5e5804c4a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 10 Aug 2023 16:44:28 +1000 Subject: [PATCH 38/50] Fixed an issue preventing notifications from working Fixed an issue where Storage could be left in an invalid state when it was completed silently before properly getting setup --- .../NotificationServiceExtension.swift | 12 ++++++++---- SessionUtilitiesKit/Database/Storage.swift | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index b4f40742a..22c6f4948 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -227,6 +227,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // to process new messages. guard !didPerformSetup else { return } + NSLog("[NotificationServiceExtension] Performing setup") didPerformSetup = true _ = AppVersion.sharedInstance() @@ -243,7 +244,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension switch result { // Only 'NSLog' works in the extension - viewable via Console.app case .failure(let error): - NSLog("[NotificationServiceExtension] Failed to complete migrations") + NSLog("[NotificationServiceExtension] Failed to complete migrations: \(error)") self?.completeSilenty() case .success: @@ -288,7 +289,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension guard !AppReadiness.isAppReady() else { return } // App isn't ready until storage is ready AND all version migrations are complete. - guard Storage.shared.isValid && migrationsCompleted else { return } + guard Storage.shared.isValid && migrationsCompleted else { + NSLog("[NotificationServiceExtension] Storage invalid") + self.completeSilenty() + return + } SignalUtilitiesKit.Configuration.performMainSetup() @@ -305,8 +310,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } private func completeSilenty() { - SNLog("Complete silenty") - + NSLog("[NotificationServiceExtension] Complete silently") Storage.suspendDatabaseAccess() self.contentHandler!(.init()) diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 94acc226b..464bf8eb5 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -375,14 +375,14 @@ open class Storage { /// database and other files into the App folder public static func suspendDatabaseAccess(using dependencies: Dependencies = Dependencies()) { NotificationCenter.default.post(name: Database.suspendNotification, object: self) - dependencies.storage.isSuspendedUnsafe = true + if Storage.hasCreatedValidInstance { dependencies.storage.isSuspendedUnsafe = true } } /// This method reverses the database suspension used to prevent the `0xdead10cc` exception (see `suspendDatabaseAccess()` /// above for more information public static func resumeDatabaseAccess(using dependencies: Dependencies = Dependencies()) { NotificationCenter.default.post(name: Database.resumeNotification, object: self) - dependencies.storage.isSuspendedUnsafe = false + if Storage.hasCreatedValidInstance { dependencies.storage.isSuspendedUnsafe = false } } public static func resetAllStorage() { From d863004e6dc9b6f5c5ce86309b16ab0fd58ba817 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 11 Aug 2023 18:02:06 +1000 Subject: [PATCH 39/50] Added a setting to control community message request polling Added logic to broadcast the community message request acceptance to SOGS so we can communicate it to message request senders Fixed an issue where database setting changes wouldn't trigger a live update on a settings screen Fixed an issue where some setting toggles wouldn't animate the state change Fixed a rarw force-unwrap crash --- LibSession-Util | 2 +- Session.xcodeproj/project.pbxproj | 8 ++ Session/Conversations/ConversationVC.swift | 48 ++++---- .../Settings/ThreadSettingsViewModel.swift | 18 ++- .../Translations/de.lproj/Localizable.strings | 4 + .../Translations/en.lproj/Localizable.strings | 4 + .../Translations/es.lproj/Localizable.strings | 4 + .../Translations/fa.lproj/Localizable.strings | 4 + .../Translations/fi.lproj/Localizable.strings | 4 + .../Translations/fr.lproj/Localizable.strings | 4 + .../Translations/hi.lproj/Localizable.strings | 4 + .../Translations/hr.lproj/Localizable.strings | 4 + .../id-ID.lproj/Localizable.strings | 4 + .../Translations/it.lproj/Localizable.strings | 4 + .../Translations/ja.lproj/Localizable.strings | 4 + .../Translations/nl.lproj/Localizable.strings | 4 + .../Translations/pl.lproj/Localizable.strings | 4 + .../pt_BR.lproj/Localizable.strings | 4 + .../Translations/ru.lproj/Localizable.strings | 4 + .../Translations/si.lproj/Localizable.strings | 4 + .../Translations/sk.lproj/Localizable.strings | 4 + .../Translations/sv.lproj/Localizable.strings | 4 + .../Translations/th.lproj/Localizable.strings | 4 + .../vi-VN.lproj/Localizable.strings | 4 + .../zh-Hant.lproj/Localizable.strings | 4 + .../zh_CN.lproj/Localizable.strings | 4 + .../ConversationSettingsViewModel.swift | 32 +++++- .../NotificationSettingsViewModel.swift | 66 ++++++++--- .../Settings/PrivacySettingsViewModel.swift | 106 +++++++++++++++--- .../Shared/SessionTableViewController.swift | 9 +- Session/Shared/SessionTableViewModel.swift | 9 +- .../Shared/Types/SessionCell+Accessory.swift | 45 +++++--- .../Views/SessionCell+AccessoryView.swift | 16 ++- Session/Shared/Views/SessionCell.swift | 8 +- Session/Utilities/MockDataGenerator.swift | 9 +- SessionMessagingKit/Configuration.swift | 3 +- .../Migrations/_003_YDBToGRDBMigration.swift | 9 +- .../_005_FixDeletedMessageReadState.swift | 2 +- .../_006_FixHiddenModAdminSupport.swift | 2 +- .../_007_HomeQueryOptimisationIndexes.swift | 2 +- .../Migrations/_008_EmojiReacts.swift | 2 +- .../Migrations/_009_OpenGroupPermission.swift | 2 +- .../_011_AddPendingReadReceipts.swift | 2 +- .../Migrations/_012_AddFTSIfNeeded.swift | 2 +- .../_015_BlockCommunityMessageRequests.swift | 33 ++++++ .../Database/Models/Profile.swift | 29 ++++- .../VisibleMessage+Profile.swift | 22 +++- .../Open Groups/OpenGroupAPI.swift | 12 +- .../Protos/Generated/SNProto.swift | 14 +++ .../Protos/Generated/SessionProtos.pb.swift | 56 ++++++++- .../Generated/WebSocketResources.pb.swift | 7 ++ .../Protos/SessionProtos.proto | 29 ++--- .../MessageReceiver+VisibleMessages.swift | 1 + .../Sending & Receiving/MessageSender.swift | 3 +- .../SessionUtil+Contacts.swift | 3 +- .../Config Handling/SessionUtil+Shared.swift | 24 ++++ .../SessionUtil+UserProfile.swift | 26 +++++ .../Database/Setting+Utilities.swift | 58 ++++++++++ .../SessionThreadViewModel.swift | 6 +- .../Utilities/Preferences.swift | 3 + .../Utilities/ProfileManager.swift | 7 ++ .../Configs/ConfigUserProfileSpec.swift | 15 +++ .../ShareNavController.swift | 4 +- .../Combine/Publisher+Utilities.swift | 17 +++ .../Database/Models/Setting.swift | 48 +++++++- 65 files changed, 766 insertions(+), 141 deletions(-) create mode 100644 SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift create mode 100644 SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift diff --git a/LibSession-Util b/LibSession-Util index d8f07fa92..e3ccf29db 160000 --- a/LibSession-Util +++ b/LibSession-Util @@ -1 +1 @@ -Subproject commit d8f07fa92c12c5c2409774e03e03395d7847d1c2 +Subproject commit e3ccf29db08aaf0b9bb6bbe72ae5967cd183a78d diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index cac7103f7..e0f3f7efe 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -531,6 +531,8 @@ 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 */; }; + FD1D732A2A85AA2000E3F410 /* Setting+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */; }; + FD1D732E2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */; }; FD23CE1B2A651E6D0000B97C /* NetworkType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1A2A651E6D0000B97C /* NetworkType.swift */; }; FD23CE1F2A65269C0000B97C /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1E2A65269C0000B97C /* Crypto.swift */; }; FD23CE222A661D000000B97C /* OpenGroupAPI+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */; }; @@ -1689,6 +1691,8 @@ 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 = ""; }; + FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Setting+Utilities.swift"; sourceTree = ""; }; + FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _015_BlockCommunityMessageRequests.swift; sourceTree = ""; }; FD23CE1A2A651E6D0000B97C /* NetworkType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkType.swift; sourceTree = ""; }; FD23CE1E2A65269C0000B97C /* Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; }; FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPI+Crypto.swift"; sourceTree = ""; }; @@ -3618,6 +3622,7 @@ FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */, FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */, FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */, + FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */, ); path = Migrations; sourceTree = ""; @@ -3763,6 +3768,7 @@ FD2B4B022949886900AB4848 /* Database */ = { isa = PBXGroup; children = ( + FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */, FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */, ); path = Database; @@ -5914,12 +5920,14 @@ C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, + FD1D732E2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift in Sources */, FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */, FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD245C56285065EA00B966DD /* SNProto.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, + FD1D732A2A85AA2000E3F410 /* Setting+Utilities.swift in Sources */, FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index fa5656fa1..523cdd884 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -208,17 +208,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers }() private lazy var emptyStateLabel: UILabel = { - let text: String = String( - format: { - switch (viewModel.threadData.threadIsNoteToSelf, viewModel.threadData.canWrite) { - case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() - case (_, false): return "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() - default: return "CONVERSATION_EMPTY_STATE".localized() - } - }(), - viewModel.threadData.displayName - ) - + let text: String = emptyStateText(for: viewModel.threadData) let result: UILabel = UILabel() result.accessibilityLabel = "Empty state label" result.translatesAutoresizingMaskIntoConstraints = false @@ -698,6 +688,24 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers self.viewModel.onInteractionChange = nil } + private func emptyStateText(for threadData: SessionThreadViewModel) -> String { + return String( + format: { + switch (threadData.threadIsNoteToSelf, threadData.canWrite) { + case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() + case (_, false): + return (threadData.profile?.blocksCommunityMessageRequests == true ? + "COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE".localized() : + "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() + ) + + default: return "CONVERSATION_EMPTY_STATE".localized() + } + }(), + threadData.displayName + ) + } + private func handleThreadUpdates(_ updatedThreadData: SessionThreadViewModel, initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) @@ -738,17 +746,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers ) // Update the empty state - let text: String = String( - format: { - switch (updatedThreadData.threadIsNoteToSelf, updatedThreadData.canWrite) { - case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() - case (_, false): return "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() - default: return "CONVERSATION_EMPTY_STATE".localized() - } - }(), - updatedThreadData.displayName - ) - + let text: String = emptyStateText(for: updatedThreadData) emptyStateLabel.attributedText = NSAttributedString(string: text) .adding( attributes: [.font: UIFont.boldSystemFont(ofSize: Values.verySmallFontSize)], @@ -791,8 +789,10 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers updatedThreadData.threadRequiresApproval == true ) self?.messageRequestStackView.isHidden = ( - updatedThreadData.threadIsMessageRequest == false && - updatedThreadData.threadRequiresApproval == false + !updatedThreadData.canWrite || ( + updatedThreadData.threadIsMessageRequest == false && + updatedThreadData.threadRequiresApproval == false + ) ) self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true) self?.messageRequestDescriptionLabelBottomConstraint?.constant = (updatedThreadData.threadRequiresApproval == true ? -4 : -20) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 40792b3fc..07251bcc5 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -178,6 +178,7 @@ class ThreadSettingsViewModel: SessionTableViewModel [SectionModel] in + .trackingConstantRegion { [weak self] db -> State in + State( + trimOpenGroupMessagesOlderThanSixMonths: db[.trimOpenGroupMessagesOlderThanSixMonths], + shouldAutoPlayConsecutiveAudioMessages: db[.shouldAutoPlayConsecutiveAudioMessages] + ) + } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[ConversationSettingsViewModel] Observation failed with error: \($0)") }) + .publisher(in: Storage.shared) + .withPrevious() + .map { (previous: State?, current: State) -> [SectionModel] in return [ SectionModel( model: .messageTrimming, @@ -55,7 +70,11 @@ class ConversationSettingsViewModel: SessionTableViewModel { +class NotificationSettingsViewModel: SessionTableViewModel { // MARK: - Config public enum Section: SessionTableSection { @@ -31,7 +31,7 @@ class NotificationSettingsViewModel: SessionTableViewModel [SectionModel] in - let notificationSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType) - + .trackingConstantRegion { db -> State in + State( + isUsingFullAPNs: false, // Set later the the data flow + notificationSound: db[.defaultNotificationSound] + .defaulting(to: Preferences.Sound.defaultNotificationSound), + playNotificationSoundInForeground: db[.playNotificationSoundInForeground], + previewType: db[.preferencesNotificationPreviewType] + .defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType) + ) + } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[NotificationSettingsViewModel] Observation failed with error: \($0)") }) + .publisher(in: Storage.shared) + .manualRefreshFrom(forcedRefresh) + .map { dbState -> State in + State( + isUsingFullAPNs: UserDefaults.standard[.isUsingFullAPNs], + notificationSound: dbState.notificationSound, + playNotificationSoundInForeground: dbState.playNotificationSoundInForeground, + previewType: dbState.previewType + ) + } + .withPrevious() + .map { (previous: State?, current: State) -> [SectionModel] in return [ SectionModel( model: .strategy, @@ -68,20 +93,24 @@ class NotificationSettingsViewModel: SessionTableViewModel [SectionModel] in + .trackingConstantRegion { [weak self] db -> State in + State( + isScreenLockEnabled: db[.isScreenLockEnabled], + checkForCommunityMessageRequests: db[.checkForCommunityMessageRequests], + areReadReceiptsEnabled: db[.areReadReceiptsEnabled], + typingIndicatorsEnabled: db[.typingIndicatorsEnabled], + areLinkPreviewsEnabled: db[.areLinkPreviewsEnabled], + areCallsEnabled: db[.areCallsEnabled] + ) + } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[PrivacySettingsViewModel] Observation failed with error: \($0)") }) + .publisher(in: Storage.shared) + .withPrevious() + .map { (previous: State?, current: State) -> [SectionModel] in return [ SectionModel( model: .screenSecurity, @@ -96,7 +122,13 @@ class PrivacySettingsViewModel: SessionTableViewModel 100 } // Prevent too many changes from causing performance issues ) { [weak self] updatedData in self?.viewModel.updateTableData(updatedData) @@ -339,6 +339,7 @@ class SessionTableViewController { Just(nil).eraseToAnyPublisher() } open var rightNavItems: AnyPublisher<[NavItem]?, Never> { Just(nil).eraseToAnyPublisher() } + private let _forcedRefresh: PassthroughSubject = PassthroughSubject() + lazy var forcedRefresh: AnyPublisher = _forcedRefresh + .shareReplay(0) private let _showToast: PassthroughSubject<(String, ThemeValue), Never> = PassthroughSubject() lazy var showToast: AnyPublisher<(String, ThemeValue), Never> = _showToast .shareReplay(0) @@ -62,6 +65,10 @@ class SessionTableViewModel( for viewModel: SessionTableViewModel ) -> AnyPublisher<(Output, StagedChangeset), Failure> where Output == [ArraySection>] { diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index af9d617eb..4bacade56 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -394,19 +394,30 @@ extension SessionCell.Accessory { extension SessionCell.Accessory { public enum DataSource: Hashable, Equatable { - case boolValue(Bool) + case boolValue(key: String, value: Bool, oldValue: Bool) case dynamicString(() -> String?) - case userDefaults(UserDefaults, key: String) - case settingBool(key: Setting.BoolKey) + + static func boolValue(_ value: Bool, oldValue: Bool) -> DataSource { + return .boolValue(key: "", value: value, oldValue: oldValue) + } + + static func boolValue(key: Setting.BoolKey, value: Bool, oldValue: Bool) -> DataSource { + return .boolValue(key: key.rawValue, value: value, oldValue: oldValue) + } // MARK: - Convenience public var currentBoolValue: Bool { switch self { - case .boolValue(let value): return value + case .boolValue(_, let value, _): return value case .dynamicString: return false - case .userDefaults(let defaults, let key): return defaults.bool(forKey: key) - case .settingBool(let key): return Storage.shared[key] + } + } + + public var oldBoolValue: Bool { + switch self { + case .boolValue(_, _, let oldValue): return oldValue + default: return false } } @@ -421,27 +432,27 @@ extension SessionCell.Accessory { public func hash(into hasher: inout Hasher) { switch self { - case .boolValue(let value): value.hash(into: &hasher) + case .boolValue(let key, let value, let oldValue): + key.hash(into: &hasher) + value.hash(into: &hasher) + oldValue.hash(into: &hasher) + case .dynamicString(let generator): generator().hash(into: &hasher) - case .userDefaults(_, let key): key.hash(into: &hasher) - case .settingBool(let key): key.hash(into: &hasher) } } public static func == (lhs: DataSource, rhs: DataSource) -> Bool { switch (lhs, rhs) { - case (.boolValue(let lhsValue), .boolValue(let rhsValue)): - return (lhsValue == rhsValue) + case (.boolValue(let lhsKey, let lhsValue, let lhsOldValue), .boolValue(let rhsKey, let rhsValue, let rhsOldValue)): + return ( + lhsKey == rhsKey && + lhsValue == rhsValue && + lhsOldValue == rhsOldValue + ) case (.dynamicString(let lhsGenerator), .dynamicString(let rhsGenerator)): return (lhsGenerator() == rhsGenerator()) - case (.userDefaults(_, let lhsKey), .userDefaults(_, let rhsKey)): - return (lhsKey == rhsKey) - - case (.settingBool(let lhsKey), .settingBool(let rhsKey)): - return (lhsKey == rhsKey) - default: return false } } diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 44c81b9eb..39ca4344a 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -277,7 +277,8 @@ extension SessionCell { public func update( with accessory: Accessory?, tintColor: ThemeValue, - isEnabled: Bool + isEnabled: Bool, + isManualReload: Bool ) { guard let accessory: Accessory = accessory else { return } @@ -356,10 +357,15 @@ extension SessionCell { fixedWidthConstraint.isActive = true toggleSwitchConstraints.forEach { $0.isActive = true } - let newValue: Bool = dataSource.currentBoolValue - - if newValue != toggleSwitch.isOn { - toggleSwitch.setOn(newValue, animated: true) + if !isManualReload { + toggleSwitch.setOn(dataSource.oldBoolValue, animated: false) + + // Dispatch so the cell reload doesn't conflict with the setting change animation + if dataSource.oldBoolValue != dataSource.currentBoolValue { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak toggleSwitch] in + toggleSwitch?.setOn(dataSource.currentBoolValue, animated: true) + } + } } case .dropDown(let dataSource, let accessibility): diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 912fb37a9..1b2b8630e 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -313,7 +313,7 @@ public class SessionCell: UITableViewCell { botSeparator.isHidden = true } - public func update(with info: Info) { + public func update(with info: Info, isManualReload: Bool = false) { interactionMode = (info.title?.interaction ?? .none) shouldHighlightTitle = (info.title?.interaction != .copy) titleExtraView = info.title?.extraViewGenerator?() @@ -332,7 +332,8 @@ public class SessionCell: UITableViewCell { leftAccessoryView.update( with: info.leftAccessory, tintColor: info.styling.tintColor, - isEnabled: info.isEnabled + isEnabled: info.isEnabled, + isManualReload: isManualReload ) titleStackView.isHidden = (info.title == nil && info.subtitle == nil) titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy) @@ -356,7 +357,8 @@ public class SessionCell: UITableViewCell { rightAccessoryView.update( with: info.rightAccessory, tintColor: info.styling.tintColor, - isEnabled: info.isEnabled + isEnabled: info.isEnabled, + isManualReload: isManualReload ) contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading) diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 1cbf94fbe..d80ea168b 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -99,7 +99,8 @@ enum MockDataGenerator { .compactMap { _ in stringContent.randomElement(using: &dmThreadRandomGenerator) } .joined(), lastNameUpdate: Date().timeIntervalSince1970, - lastProfilePictureUpdate: Date().timeIntervalSince1970 + lastProfilePictureUpdate: Date().timeIntervalSince1970, + lastBlocksCommunityMessageRequests: 0 ) .saved(db) @@ -180,7 +181,8 @@ enum MockDataGenerator { .compactMap { _ in stringContent.randomElement(using: &cgThreadRandomGenerator) } .joined(), lastNameUpdate: Date().timeIntervalSince1970, - lastProfilePictureUpdate: Date().timeIntervalSince1970 + lastProfilePictureUpdate: Date().timeIntervalSince1970, + lastBlocksCommunityMessageRequests: 0 ) .saved(db) @@ -310,7 +312,8 @@ enum MockDataGenerator { .compactMap { _ in stringContent.randomElement(using: &ogThreadRandomGenerator) } .joined(), lastNameUpdate: Date().timeIntervalSince1970, - lastProfilePictureUpdate: Date().timeIntervalSince1970 + lastProfilePictureUpdate: Date().timeIntervalSince1970, + lastBlocksCommunityMessageRequests: 0 ) .saved(db) diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 0aa4f5d37..8522a4276 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -37,7 +37,8 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API (Features.useSharedUtilForUserConfig(db) ? _014_GenerateInitialUserConfigDumps.self : (nil as Migration.Type?) - ) + ), + _015_BlockCommunityMessageRequests.self ].compactMap { $0 } ] ) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 8918c1c9b..9f0af5346 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -422,7 +422,8 @@ enum _003_YDBToGRDBMigration: Migration { profilePictureUrl: legacyContact.profilePictureURL, profilePictureFileName: legacyContact.profilePictureFileName, profileEncryptionKey: legacyContact.profileEncryptionKey?.keyData, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).migrationSafeInsert(db) /// **Note:** The blow "shouldForce" flags are here to allow us to avoid having to run legacy migrations they @@ -645,7 +646,8 @@ enum _003_YDBToGRDBMigration: Migration { id: profileId, name: profileId, lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).migrationSafeSave(db) } @@ -1059,7 +1061,8 @@ enum _003_YDBToGRDBMigration: Migration { id: quotedMessage.authorId, name: quotedMessage.authorId, lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).migrationSafeSave(db) } diff --git a/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift b/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift index 65e68507c..235a217d3 100644 --- a/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift +++ b/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift @@ -9,7 +9,7 @@ enum _005_FixDeletedMessageReadState: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "FixDeletedMessageReadState" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { _ = try Interaction diff --git a/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift b/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift index c1097eb94..b746c6362 100644 --- a/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift +++ b/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift @@ -10,7 +10,7 @@ enum _006_FixHiddenModAdminSupport: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "FixHiddenModAdminSupport" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.alter(table: GroupMember.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift index b468098f7..5e53bb6ee 100644 --- a/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift +++ b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift @@ -9,7 +9,7 @@ enum _007_HomeQueryOptimisationIndexes: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "HomeQueryOptimisationIndexes" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.create( diff --git a/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift b/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift index b06687dca..399dba483 100644 --- a/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift +++ b/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift @@ -9,7 +9,7 @@ enum _008_EmojiReacts: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "EmojiReacts" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.create(table: Reaction.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift b/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift index 4f6036a2d..f4c7e8617 100644 --- a/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift +++ b/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift @@ -8,7 +8,7 @@ enum _009_OpenGroupPermission: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "OpenGroupPermission" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: GRDB.Database) throws { try db.alter(table: OpenGroup.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift b/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift index 9c2e228d5..2fb57b2cf 100644 --- a/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift +++ b/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift @@ -10,7 +10,7 @@ enum _011_AddPendingReadReceipts: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "AddPendingReadReceipts" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.create(table: PendingReadReceipt.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift b/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift index d994b6a90..57cb66e7d 100644 --- a/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift +++ b/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift @@ -9,7 +9,7 @@ enum _012_AddFTSIfNeeded: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "AddFTSIfNeeded" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { // Fix an issue that the fullTextSearchTable was dropped unintentionally and global search won't work. diff --git a/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift new file mode 100644 index 000000000..f887a6ce3 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift @@ -0,0 +1,33 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +/// This migration adds a flag indicating whether a profile has indicated it is blocking community message requests +enum _015_BlockCommunityMessageRequests: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "BlockCommunityMessageRequests" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.01 + + static func migrate(_ db: Database) throws { + // Add the new 'Profile' properties + try db.alter(table: Profile.self) { t in + t.add(.blocksCommunityMessageRequests, .boolean) + t.add(.lastBlocksCommunityMessageRequests, .integer) + .notNull() + .defaults(to: 0) + } + + // If the user exists and the 'checkForCommunityMessageRequests' hasn't already been set then default it to "false" + if + Identity.userExists(db), + (try Setting.exists(db, id: Setting.BoolKey.checkForCommunityMessageRequests.rawValue)) == false + { + db[.checkForCommunityMessageRequests] = true + } + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index cc4e4bef6..a9a6bd8af 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -27,6 +27,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco case profilePictureFileName case profileEncryptionKey case lastProfilePictureUpdate + + case blocksCommunityMessageRequests + case lastBlocksCommunityMessageRequests } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) @@ -53,6 +56,12 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco /// The timestamp (in seconds since epoch) that the profile picture was last updated public let lastProfilePictureUpdate: TimeInterval + /// A flag indicating whether this profile has reported that it blocks community message requests + public let blocksCommunityMessageRequests: Bool? + + /// The timestamp (in seconds since epoch) that the `blocksCommunityMessageRequests` setting was last updated + public let lastBlocksCommunityMessageRequests: TimeInterval + // MARK: - Initialization public init( @@ -63,7 +72,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco profilePictureUrl: String? = nil, profilePictureFileName: String? = nil, profileEncryptionKey: Data? = nil, - lastProfilePictureUpdate: TimeInterval + lastProfilePictureUpdate: TimeInterval, + blocksCommunityMessageRequests: Bool? = nil, + lastBlocksCommunityMessageRequests: TimeInterval ) { self.id = id self.name = name @@ -73,6 +84,8 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco self.profilePictureFileName = profilePictureFileName self.profileEncryptionKey = profileEncryptionKey self.lastProfilePictureUpdate = lastProfilePictureUpdate + self.blocksCommunityMessageRequests = blocksCommunityMessageRequests + self.lastBlocksCommunityMessageRequests = lastBlocksCommunityMessageRequests } // MARK: - Description @@ -114,7 +127,9 @@ public extension Profile { profilePictureUrl: profilePictureUrl, profilePictureFileName: try? container.decode(String.self, forKey: .profilePictureFileName), profileEncryptionKey: profileKey, - lastProfilePictureUpdate: try container.decode(TimeInterval.self, forKey: .lastProfilePictureUpdate) + lastProfilePictureUpdate: try container.decode(TimeInterval.self, forKey: .lastProfilePictureUpdate), + blocksCommunityMessageRequests: try? container.decode(Bool.self, forKey: .blocksCommunityMessageRequests), + lastBlocksCommunityMessageRequests: try container.decode(TimeInterval.self, forKey: .lastBlocksCommunityMessageRequests) ) } @@ -129,6 +144,8 @@ public extension Profile { try container.encodeIfPresent(profilePictureFileName, forKey: .profilePictureFileName) try container.encodeIfPresent(profileEncryptionKey, forKey: .profileEncryptionKey) try container.encode(lastProfilePictureUpdate, forKey: .lastProfilePictureUpdate) + try container.encodeIfPresent(blocksCommunityMessageRequests, forKey: .blocksCommunityMessageRequests) + try container.encode(lastBlocksCommunityMessageRequests, forKey: .lastBlocksCommunityMessageRequests) } } @@ -156,7 +173,9 @@ public extension Profile { profilePictureUrl: profilePictureUrl, profilePictureFileName: nil, profileEncryptionKey: profileKey, - lastProfilePictureUpdate: sentTimestamp + lastProfilePictureUpdate: sentTimestamp, + blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil), + lastBlocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? sentTimestamp : 0) ) } @@ -242,7 +261,9 @@ public extension Profile { profilePictureUrl: nil, profilePictureFileName: nil, profileEncryptionKey: nil, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + blocksCommunityMessageRequests: nil, + lastBlocksCommunityMessageRequests: 0 ) } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 8f63ed5a9..4ad3649ac 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -10,15 +10,22 @@ public extension VisibleMessage { public let displayName: String? public let profileKey: Data? public let profilePictureUrl: String? + public let blocksCommunityMessageRequests: Bool? // MARK: - Initialization - internal init(displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil) { + internal init( + displayName: String, + profileKey: Data? = nil, + profilePictureUrl: String? = nil, + blocksCommunityMessageRequests: Bool? = nil + ) { let hasUrlAndKey: Bool = (profileKey != nil && profilePictureUrl != nil) self.displayName = displayName self.profileKey = (hasUrlAndKey ? profileKey : nil) self.profilePictureUrl = (hasUrlAndKey ? profilePictureUrl : nil) + self.blocksCommunityMessageRequests = blocksCommunityMessageRequests } // MARK: - Proto Conversion @@ -32,7 +39,8 @@ public extension VisibleMessage { return VMProfile( displayName: displayName, profileKey: proto.profileKey, - profilePictureUrl: profileProto.profilePicture + profilePictureUrl: profileProto.profilePicture, + blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil) ) } @@ -45,6 +53,10 @@ public extension VisibleMessage { let profileProto = SNProtoLokiProfile.builder() profileProto.setDisplayName(displayName) + if let blocksCommunityMessageRequests: Bool = self.blocksCommunityMessageRequests { + dataMessageProto.setBlocksCommunityMessageRequests(blocksCommunityMessageRequests) + } + if let profileKey = profileKey, let profilePictureUrl = profilePictureUrl { dataMessageProto.setProfileKey(profileKey) profileProto.setProfilePicture(profilePictureUrl) @@ -112,10 +124,14 @@ public extension VisibleMessage { // MARK: - Conversion extension VisibleMessage.VMProfile { - init(profile: Profile) { + init( + profile: Profile, + blocksCommunityMessageRequests: Bool? + ) { self.displayName = profile.name self.profileKey = profile.profileEncryptionKey self.profilePictureUrl = profile.profilePictureUrl + self.blocksCommunityMessageRequests = blocksCommunityMessageRequests } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 4cd74f821..5c5d622c0 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -109,10 +109,12 @@ public enum OpenGroupAPI { // The 'inbox' and 'outbox' only work with blinded keys so don't bother polling them if not blinded !capabilities.contains(.blind) ? [] : [ - // Inbox - (lastInboxMessageId == 0 ? - try preparedInbox(db, on: server, using: dependencies) : - try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) + // Inbox (only check the inbox if the user want's community message requests) + (!db[.checkForCommunityMessageRequests] ? nil : + (lastInboxMessageId == 0 ? + try preparedInbox(db, on: server, using: dependencies) : + try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) + ) ), // Outbox @@ -120,7 +122,7 @@ public enum OpenGroupAPI { try preparedOutbox(db, on: server, using: dependencies) : try preparedOutboxSince(db, id: lastOutboxMessageId, on: server, using: dependencies) ), - ] + ].compactMap { $0 } ) ) diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index ed766ca8d..72e4ca517 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -2497,6 +2497,9 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr if let _value = syncTarget { builder.setSyncTarget(_value) } + if hasBlocksCommunityMessageRequests { + builder.setBlocksCommunityMessageRequests(blocksCommunityMessageRequests) + } return builder } @@ -2570,6 +2573,10 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr proto.syncTarget = valueParam } + @objc public func setBlocksCommunityMessageRequests(_ valueParam: Bool) { + proto.blocksCommunityMessageRequests = valueParam + } + @objc public func build() throws -> SNProtoDataMessage { return try SNProtoDataMessage.parseProto(proto) } @@ -2646,6 +2653,13 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr return proto.hasSyncTarget } + @objc public var blocksCommunityMessageRequests: Bool { + return proto.blocksCommunityMessageRequests + } + @objc public var hasBlocksCommunityMessageRequests: Bool { + return proto.hasBlocksCommunityMessageRequests + } + private init(proto: SessionProtos_DataMessage, attachments: [SNProtoAttachmentPointer], quote: SNProtoDataMessageQuote?, diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 6f209cb67..3fb72c9ad 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -600,7 +600,7 @@ struct SessionProtos_DataMessage { set {_uniqueStorage()._attachments = newValue} } - /// optional GroupContext group = 3; // No longer used + /// optional GroupContext group = 3; // No longer used var flags: UInt32 { get {return _storage._flags ?? 0} set {_uniqueStorage()._flags = newValue} @@ -696,6 +696,15 @@ struct SessionProtos_DataMessage { /// Clears the value of `syncTarget`. Subsequent reads from it will return its default value. mutating func clearSyncTarget() {_uniqueStorage()._syncTarget = nil} + var blocksCommunityMessageRequests: Bool { + get {return _storage._blocksCommunityMessageRequests ?? false} + set {_uniqueStorage()._blocksCommunityMessageRequests = newValue} + } + /// Returns true if `blocksCommunityMessageRequests` has been explicitly set. + var hasBlocksCommunityMessageRequests: Bool {return _storage._blocksCommunityMessageRequests != nil} + /// Clears the value of `blocksCommunityMessageRequests`. Subsequent reads from it will return its default value. + mutating func clearBlocksCommunityMessageRequests() {_uniqueStorage()._blocksCommunityMessageRequests = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() enum Flags: SwiftProtobuf.Enum { @@ -1665,6 +1674,43 @@ extension SessionProtos_SharedConfigMessage.Kind: CaseIterable { #endif // swift(>=4.2) +#if swift(>=5.5) && canImport(_Concurrency) +extension SessionProtos_Envelope: @unchecked Sendable {} +extension SessionProtos_Envelope.TypeEnum: @unchecked Sendable {} +extension SessionProtos_TypingMessage: @unchecked Sendable {} +extension SessionProtos_TypingMessage.Action: @unchecked Sendable {} +extension SessionProtos_UnsendRequest: @unchecked Sendable {} +extension SessionProtos_MessageRequestResponse: @unchecked Sendable {} +extension SessionProtos_Content: @unchecked Sendable {} +extension SessionProtos_CallMessage: @unchecked Sendable {} +extension SessionProtos_CallMessage.TypeEnum: @unchecked Sendable {} +extension SessionProtos_KeyPair: @unchecked Sendable {} +extension SessionProtos_DataExtractionNotification: @unchecked Sendable {} +extension SessionProtos_DataExtractionNotification.TypeEnum: @unchecked Sendable {} +extension SessionProtos_LokiProfile: @unchecked Sendable {} +extension SessionProtos_DataMessage: @unchecked Sendable {} +extension SessionProtos_DataMessage.Flags: @unchecked Sendable {} +extension SessionProtos_DataMessage.Quote: @unchecked Sendable {} +extension SessionProtos_DataMessage.Quote.QuotedAttachment: @unchecked Sendable {} +extension SessionProtos_DataMessage.Quote.QuotedAttachment.Flags: @unchecked Sendable {} +extension SessionProtos_DataMessage.Preview: @unchecked Sendable {} +extension SessionProtos_DataMessage.Reaction: @unchecked Sendable {} +extension SessionProtos_DataMessage.Reaction.Action: @unchecked Sendable {} +extension SessionProtos_DataMessage.OpenGroupInvitation: @unchecked Sendable {} +extension SessionProtos_DataMessage.ClosedGroupControlMessage: @unchecked Sendable {} +extension SessionProtos_DataMessage.ClosedGroupControlMessage.TypeEnum: @unchecked Sendable {} +extension SessionProtos_DataMessage.ClosedGroupControlMessage.KeyPairWrapper: @unchecked Sendable {} +extension SessionProtos_ConfigurationMessage: @unchecked Sendable {} +extension SessionProtos_ConfigurationMessage.ClosedGroup: @unchecked Sendable {} +extension SessionProtos_ConfigurationMessage.Contact: @unchecked Sendable {} +extension SessionProtos_ReceiptMessage: @unchecked Sendable {} +extension SessionProtos_ReceiptMessage.TypeEnum: @unchecked Sendable {} +extension SessionProtos_AttachmentPointer: @unchecked Sendable {} +extension SessionProtos_AttachmentPointer.Flags: @unchecked Sendable {} +extension SessionProtos_SharedConfigMessage: @unchecked Sendable {} +extension SessionProtos_SharedConfigMessage.Kind: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "SessionProtos" @@ -2288,6 +2334,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa 102: .same(proto: "openGroupInvitation"), 104: .same(proto: "closedGroupControlMessage"), 105: .same(proto: "syncTarget"), + 106: .same(proto: "blocksCommunityMessageRequests"), ] fileprivate class _StorageClass { @@ -2304,6 +2351,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa var _openGroupInvitation: SessionProtos_DataMessage.OpenGroupInvitation? = nil var _closedGroupControlMessage: SessionProtos_DataMessage.ClosedGroupControlMessage? = nil var _syncTarget: String? = nil + var _blocksCommunityMessageRequests: Bool? = nil static let defaultInstance = _StorageClass() @@ -2323,6 +2371,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa _openGroupInvitation = source._openGroupInvitation _closedGroupControlMessage = source._closedGroupControlMessage _syncTarget = source._syncTarget + _blocksCommunityMessageRequests = source._blocksCommunityMessageRequests } } @@ -2366,6 +2415,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa case 102: try { try decoder.decodeSingularMessageField(value: &_storage._openGroupInvitation) }() case 104: try { try decoder.decodeSingularMessageField(value: &_storage._closedGroupControlMessage) }() case 105: try { try decoder.decodeSingularStringField(value: &_storage._syncTarget) }() + case 106: try { try decoder.decodeSingularBoolField(value: &_storage._blocksCommunityMessageRequests) }() default: break } } @@ -2417,6 +2467,9 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa try { if let v = _storage._syncTarget { try visitor.visitSingularStringField(value: v, fieldNumber: 105) } }() + try { if let v = _storage._blocksCommunityMessageRequests { + try visitor.visitSingularBoolField(value: v, fieldNumber: 106) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -2439,6 +2492,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa if _storage._openGroupInvitation != rhs_storage._openGroupInvitation {return false} if _storage._closedGroupControlMessage != rhs_storage._closedGroupControlMessage {return false} if _storage._syncTarget != rhs_storage._syncTarget {return false} + if _storage._blocksCommunityMessageRequests != rhs_storage._blocksCommunityMessageRequests {return false} return true } if !storagesAreEqual {return false} diff --git a/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift b/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift index 737e40ce6..2fe165044 100644 --- a/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift +++ b/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift @@ -218,6 +218,13 @@ extension WebSocketProtos_WebSocketMessage.TypeEnum: CaseIterable { #endif // swift(>=4.2) +#if swift(>=5.5) && canImport(_Concurrency) +extension WebSocketProtos_WebSocketRequestMessage: @unchecked Sendable {} +extension WebSocketProtos_WebSocketResponseMessage: @unchecked Sendable {} +extension WebSocketProtos_WebSocketMessage: @unchecked Sendable {} +extension WebSocketProtos_WebSocketMessage.TypeEnum: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "WebSocketProtos" diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index 429c10b14..55d77b18f 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -192,20 +192,21 @@ message DataMessage { optional uint32 expirationTimer = 8; } - optional string body = 1; - repeated AttachmentPointer attachments = 2; - // optional GroupContext group = 3; // No longer used - optional uint32 flags = 4; - optional uint32 expireTimer = 5; - optional bytes profileKey = 6; - optional uint64 timestamp = 7; - optional Quote quote = 8; - repeated Preview preview = 10; - optional Reaction reaction = 11; - optional LokiProfile profile = 101; - optional OpenGroupInvitation openGroupInvitation = 102; - optional ClosedGroupControlMessage closedGroupControlMessage = 104; - optional string syncTarget = 105; + optional string body = 1; + repeated AttachmentPointer attachments = 2; + // optional GroupContext group = 3; // No longer used + optional uint32 flags = 4; + optional uint32 expireTimer = 5; + optional bytes profileKey = 6; + optional uint64 timestamp = 7; + optional Quote quote = 8; + repeated Preview preview = 10; + optional Reaction reaction = 11; + optional LokiProfile profile = 101; + optional OpenGroupInvitation openGroupInvitation = 102; + optional ClosedGroupControlMessage closedGroupControlMessage = 104; + optional string syncTarget = 105; + optional bool blocksCommunityMessageRequests = 106; } message ConfigurationMessage { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index a1a959306..4b2573c5e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -31,6 +31,7 @@ extension MessageReceiver { db, publicKey: sender, name: profile.displayName, + blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, avatarUpdate: { guard let profilePictureUrl: String = profile.profilePictureUrl, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 9b968bdab..747841f4e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -436,7 +436,8 @@ public final class MessageSender { // Attach the user's profile message.profile = VisibleMessage.VMProfile( - profile: Profile.fetchOrCreateCurrentUser() + profile: Profile.fetchOrCreateCurrentUser(db), + blocksCommunityMessageRequests: !db[.checkForCommunityMessageRequests] ) if (message.profile?.displayName ?? "").isEmpty { diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift index 019b19829..b6a8b86f0 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -573,7 +573,8 @@ private extension SessionUtil { count: ProfileManager.avatarAES256KeyByteLength ) ), - lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000) + lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000), + lastBlocksCommunityMessageRequests: 0 ) result[contactId] = ContactData( diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift index 909ea9ce7..c05bc17c3 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift @@ -213,6 +213,30 @@ internal extension SessionUtil { return updated } + static func updatingSetting(_ db: Database, _ updated: Setting?) throws { + // Don't current support any nullable settings + guard let updatedSetting: Setting = updated else { return } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // Currently the only synced setting is 'checkForCommunityMessageRequests' + switch updatedSetting.id { + case Setting.BoolKey.checkForCommunityMessageRequests.rawValue: + try SessionUtil.performAndPushChange( + db, + for: .userProfile, + publicKey: userPublicKey + ) { conf in + try SessionUtil.updateSettings( + checkForCommunityMessageRequests: updatedSetting.unsafeValue(as: Bool.self), + in: conf + ) + } + + default: break + } + } + static func kickFromConversationUIIfNeeded(removedThreadIds: [String]) { guard !removedThreadIds.isEmpty else { return } diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift index ed522930e..44ed7b2b2 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -12,6 +12,10 @@ internal extension SessionUtil { Profile.Columns.profileEncryptionKey ] + static let syncedSettings: [String] = [ + Setting.BoolKey.checkForCommunityMessageRequests.rawValue + ] + // MARK: - Incoming Changes static func handleUserProfileUpdate( @@ -115,6 +119,17 @@ internal extension SessionUtil { } } + // Update settings if needed + let updatedAllowBlindedMessageRequests: Int32 = user_profile_get_blinded_msgreqs(conf) + let updatedAllowBlindedMessageRequestsBoolValue: Bool = (updatedAllowBlindedMessageRequests >= 1) + + if + updatedAllowBlindedMessageRequests >= 0 && + updatedAllowBlindedMessageRequestsBoolValue != db[.checkForCommunityMessageRequests] + { + db[.checkForCommunityMessageRequests] = updatedAllowBlindedMessageRequestsBoolValue + } + // Create a contact for the current user if needed (also force-approve the current user // in case the account got into a weird state or restored directly from a migration) let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) @@ -159,4 +174,15 @@ internal extension SessionUtil { user_profile_set_nts_priority(conf, priority) } + + static func updateSettings( + checkForCommunityMessageRequests: Bool? = nil, + in conf: UnsafeMutablePointer? + ) throws { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + if let blindedMessageRequests: Bool = checkForCommunityMessageRequests { + user_profile_set_blinded_msgreqs(conf, (blindedMessageRequests ? 1 : 0)) + } + } } diff --git a/SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift b/SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift new file mode 100644 index 000000000..2e178c5b1 --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift @@ -0,0 +1,58 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public extension Database { + func setAndUpdateConfig(_ key: Setting.BoolKey, to newValue: Bool) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.DoubleKey, to newValue: Double?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.IntKey, to newValue: Int?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.StringKey, to newValue: String?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.EnumKey, to newValue: T?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.EnumKey, to newValue: T?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + /// Value will be stored as a timestamp in seconds since 1970 + func setAndUpdateConfig(_ key: Setting.DateKey, to newValue: Date?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + private func updateConfigIfNeeded( + _ db: Database, + key: String, + updatedSetting: Setting? + ) throws { + // Before we do anything custom make sure the setting should trigger a change + guard SessionUtil.syncedSettings.contains(key) else { return } + + defer { + // If we changed a column that requires a config update then we may as well automatically + // enqueue a new config sync job once the transaction completes (but only enqueue it once + // per transaction - doing it more than once is pointless) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(userPublicKey)) { db in + ConfigurationSyncJob.enqueue(db, publicKey: userPublicKey) + } + } + + try SessionUtil.updatingSetting(db, updatedSetting) + } +} diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 0ee0f5f5a..c9d0c43eb 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -104,7 +104,11 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var canWrite: Bool { switch threadVariant { - case .contact: return true + case .contact: + guard threadIsMessageRequest == true else { return true } + + return (profile?.blocksCommunityMessageRequests != true) + case .legacyGroup, .group: return ( currentUserIsClosedGroupMember == true && diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 34a00860e..4713e8ce1 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -65,6 +65,9 @@ public extension Setting.BoolKey { /// Controls whether concurrent audio messages should automatically be played after the one the user starts /// playing finishes static let shouldAutoPlayConsecutiveAudioMessages: Setting.BoolKey = "shouldAutoPlayConsecutiveAudioMessages" + + /// Controls whether the device will poll for community message requests (SOGS `/inbox` endpoint) + static let checkForCommunityMessageRequests: Setting.BoolKey = "checkForCommunityMessageRequests" } public extension Setting.StringKey { diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 7266b30f6..7597c683a 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -498,6 +498,7 @@ public struct ProfileManager { _ db: Database, publicKey: String, name: String?, + blocksCommunityMessageRequests: Bool? = nil, avatarUpdate: AvatarUpdate, sentTimestamp: TimeInterval, calledFromConfigHandling: Bool = false, @@ -516,6 +517,12 @@ public struct ProfileManager { } } + // Blocks community message requets flag + if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests, sentTimestamp > profile.lastBlocksCommunityMessageRequests { + profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: blocksCommunityMessageRequests)) + profileChanges.append(Profile.Columns.lastBlocksCommunityMessageRequests.set(to: sentTimestamp)) + } + // Profile picture & profile key var avatarNeedsDownload: Bool = false var targetAvatarUrl: String? = nil diff --git a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift index ce0ba7a6e..d7f08b3a1 100644 --- a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift +++ b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift @@ -285,6 +285,17 @@ class ConfigUserProfileSpec { ) user_profile_set_pic(conf2, p2) + user_profile_set_nts_expiry(conf2, 86200) + expect(user_profile_get_nts_expiry(conf2)).to(equal(86400)) + + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(-1)) + user_profile_set_blinded_msgreqs(conf2, 0) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(0)) + user_profile_set_blinded_msgreqs(conf2, -1) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(-1)) + user_profile_set_blinded_msgreqs(conf2, 1) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(1)) + // Both have changes, so push need a push expect(config_needs_push(conf)).to(beTrue()) expect(config_needs_push(conf2)).to(beTrue()) @@ -364,6 +375,10 @@ class ConfigUserProfileSpec { .to(equal("7177657274007975696f31323334353637383930313233343536373839303132")) expect(user_profile_get_nts_priority(conf)).to(equal(9)) expect(user_profile_get_nts_priority(conf2)).to(equal(9)) + expect(user_profile_get_nts_expiry(conf)).to(equal(86400)) + expect(user_profile_get_nts_expiry(conf2)).to(equal(86400)) + expect(user_profile_get_blinded_msgreqs(conf)).to(equal(1)) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(1)) let fakeHash4: String = "fakehash4" var cFakeHash4: [CChar] = fakeHash4.cArray.nullTerminated() diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 457d5e277..c2d6c1ad6 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -211,11 +211,11 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { } func shareViewWasCompleted() { - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } func shareViewWasCancelled() { - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } func shareViewFailed(error: Error) { diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index a0985631e..c4a2dc0cb 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -65,6 +65,23 @@ public extension Publisher { return self.receive(on: scheduler, options: options) .eraseToAnyPublisher() } + + func manualRefreshFrom(_ refreshTrigger: some Publisher) -> AnyPublisher { + return Publishers + .CombineLatest(refreshTrigger.prepend(()).setFailureType(to: Failure.self), self) + .map { _, value in value } + .eraseToAnyPublisher() + } + + func withPrevious() -> AnyPublisher<(previous: Output?, current: Output), Failure> { + scan(Optional<(Output?, Output)>.none) { ($0?.1, $1) } + .compactMap { $0 } + .eraseToAnyPublisher() + } + + func withPrevious(_ initialPreviousValue: Output) -> AnyPublisher<(previous: Output, current: Output), Failure> { + scan((initialPreviousValue, initialPreviousValue)) { ($0.1, $1) }.eraseToAnyPublisher() + } } // MARK: - Convenience diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index 1f634dac5..163fd01b8 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -15,6 +15,7 @@ public struct Setting: Codable, Identifiable, FetchableRecord, PersistableRecord } public var id: String { key } + public var rawValue: Data { value } let key: String let value: Data @@ -53,7 +54,7 @@ extension Setting { self.value = Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) } - fileprivate func value(as type: Bool.Type) -> Bool? { + public func unsafeValue(as type: Bool.Type) -> Bool? { // Note: The 'assumingMemoryBound' is essentially going to try to convert // the memory into the provided type so can result in invalid data being // returned if the type is incorrect. But it does seem safer than the 'load' @@ -189,7 +190,7 @@ public extension Database { subscript(key: Setting.BoolKey) -> Bool { get { // Default to false if it doesn't exist - (self[key.rawValue]?.value(as: Bool.self) ?? false) + (self[key.rawValue]?.unsafeValue(as: Bool.self) ?? false) } set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } } @@ -245,4 +246,47 @@ public extension Database { ) } } + + func setting(key: Setting.BoolKey, to newValue: Bool) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.DoubleKey, to newValue: Double?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.IntKey, to newValue: Int?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.StringKey, to newValue: String?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.EnumKey, to newValue: T?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue?.rawValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.EnumKey, to newValue: T?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue?.rawValue) + self[key.rawValue] = result + return result + } + + /// Value will be stored as a timestamp in seconds since 1970 + func setting(key: Setting.DateKey, to newValue: Date?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue.map { $0.timeIntervalSince1970 }) + self[key.rawValue] = result + return result + } } From 26c6df78abfee3163613bd4815768abf0b8d5002 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 11 Aug 2023 18:04:15 +1000 Subject: [PATCH 40/50] Fixed test compilation issues --- .../Settings/ThreadSettingsViewModelSpec.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 22fe69cda..c271f6a3e 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -60,14 +60,16 @@ class ThreadSettingsViewModelSpec: QuickSpec { id: "05\(TestConstants.publicKey)", name: "TestMe", lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).insert(db) try Profile( id: "TestId", name: "TestUser", lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).insert(db) } viewModel = ThreadSettingsViewModel( From ef5aa927a0ccfb8ae02b77a2bd75d9269c74134c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 11 Aug 2023 18:48:14 +1000 Subject: [PATCH 41/50] Added logic to use the setting if it's already been sent in a config Added the ability to define requirements for migrations (in case some data or state needs to be loaded for a migration to be able to be performed correctly) --- Session.xcodeproj/project.pbxproj | 4 +++ .../_015_BlockCommunityMessageRequests.swift | 13 +++++++- .../Config Handling/SessionUtil+Shared.swift | 16 ++++++++++ .../SessionUtil+UserProfile.swift | 10 ++++++ SessionUtilitiesKit/Database/Storage.swift | 31 +++++++++++++++++++ .../Database/Types/Migration.swift | 4 +++ .../Database/Types/MigrationRequirement.swift | 12 +++++++ SignalUtilitiesKit/Utilities/AppSetup.swift | 16 ++++++++++ 8 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 SessionUtilitiesKit/Database/Types/MigrationRequirement.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 17a813494..a83905cf0 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -517,6 +517,7 @@ FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD1D732A2A85AA2000E3F410 /* Setting+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */; }; FD1D732E2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */; }; + FD1F9C9F2A862BE60050F671 /* MigrationRequirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F9C9E2A862BE60050F671 /* MigrationRequirement.swift */; }; FD23CE1B2A651E6D0000B97C /* NetworkType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1A2A651E6D0000B97C /* NetworkType.swift */; }; FD23CE1F2A65269C0000B97C /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1E2A65269C0000B97C /* Crypto.swift */; }; FD23CE222A661D000000B97C /* OpenGroupAPI+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */; }; @@ -1675,6 +1676,7 @@ FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Setting+Utilities.swift"; sourceTree = ""; }; FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _015_BlockCommunityMessageRequests.swift; sourceTree = ""; }; + FD1F9C9E2A862BE60050F671 /* MigrationRequirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationRequirement.swift; sourceTree = ""; }; FD23CE1A2A651E6D0000B97C /* NetworkType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkType.swift; sourceTree = ""; }; FD23CE1E2A65269C0000B97C /* Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; }; FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPI+Crypto.swift"; sourceTree = ""; }; @@ -3671,6 +3673,7 @@ children = ( FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */, FD17D7B727F51ECA00122BE0 /* Migration.swift */, + FD1F9C9E2A862BE60050F671 /* MigrationRequirement.swift */, FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */, FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */, FD37EA1028AB34B3003AE748 /* TypedTableAlteration.swift */, @@ -5678,6 +5681,7 @@ FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */, C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, FD09797D27FBDB2000936362 /* Notification+Utilities.swift in Sources */, + FD1F9C9F2A862BE60050F671 /* MigrationRequirement.swift in Sources */, FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */, C3C2AC2E2553CBEB00C340D1 /* String+Trimming.swift in Sources */, FD17D7C727F5207C00122BE0 /* DatabaseMigrator+Utilities.swift in Sources */, diff --git a/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift index f887a6ce3..b512101b2 100644 --- a/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift +++ b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift @@ -10,6 +10,7 @@ enum _015_BlockCommunityMessageRequests: Migration { static let identifier: String = "BlockCommunityMessageRequests" static let needsConfigSync: Bool = false static let minExpectedRunDuration: TimeInterval = 0.01 + static var requirements: [MigrationRequirement] = [.sessionUtilStateLoaded] static func migrate(_ db: Database) throws { // Add the new 'Profile' properties @@ -25,7 +26,17 @@ enum _015_BlockCommunityMessageRequests: Migration { Identity.userExists(db), (try Setting.exists(db, id: Setting.BoolKey.checkForCommunityMessageRequests.rawValue)) == false { - db[.checkForCommunityMessageRequests] = true + let rawBlindedMessageRequestValue: Int32 = try SessionUtil + .config(for: .userProfile, publicKey: getUserHexEncodedPublicKey(db)) + .wrappedValue + .map { conf -> Int32 in try SessionUtil.rawBlindedMessageRequestValue(in: conf) } + .defaulting(to: -1) + + // Use the value in the config if we happen to have one, otherwise use the default + db[.checkForCommunityMessageRequests] = (rawBlindedMessageRequestValue < 0 ? + true : + (rawBlindedMessageRequestValue > 0) + ) } Storage.update(progress: 1, for: self, in: target) // In case this is the last migration diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift index ce2500f1c..4a9c8d286 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift @@ -210,6 +210,22 @@ internal extension SessionUtil { return updated } + static func hasSetting(_ db: Database, forKey key: String) throws -> Bool { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // Currently the only synced setting is 'checkForCommunityMessageRequests' + switch key { + case Setting.BoolKey.checkForCommunityMessageRequests.rawValue: + return try SessionUtil + .config(for: .userProfile, publicKey: userPublicKey) + .wrappedValue + .map { conf -> Bool in (try SessionUtil.rawBlindedMessageRequestValue(in: conf) >= 0) } + .defaulting(to: false) + + default: return false + } + } + static func updatingSetting(_ db: Database, _ updated: Setting?) throws { // Don't current support any nullable settings guard let updatedSetting: Setting = updated else { return } diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift index 44ed7b2b2..4416eee82 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -186,3 +186,13 @@ internal extension SessionUtil { } } } + +// MARK: - Direct Values + +extension SessionUtil { + static func rawBlindedMessageRequestValue(in conf: UnsafeMutablePointer?) throws -> Int32 { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + return user_profile_get_blinded_msgreqs(conf) + } +} diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 464bf8eb5..2278e4670 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -47,8 +47,10 @@ open class Storage { fileprivate var dbWriter: DatabaseWriter? internal var testDbWriter: DatabaseWriter? { dbWriter } + private var unprocessedMigrationRequirements: Atomic<[MigrationRequirement]> = Atomic(MigrationRequirement.allCases) private var migrator: DatabaseMigrator? private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>? + private var migrationRequirementProcesser: Atomic<(Database?, MigrationRequirement) -> ()>? // MARK: - Initialization @@ -77,6 +79,7 @@ open class Storage { migrationTargets: (customMigrationTargets ?? []), async: false, onProgressUpdate: nil, + onMigrationRequirement: { _, _ in }, onComplete: { _, _ in } ) return @@ -148,6 +151,7 @@ open class Storage { migrationTargets: [MigratableTarget.Type], async: Bool = true, onProgressUpdate: ((CGFloat, TimeInterval) -> ())?, + onMigrationRequirement: @escaping (Database?, MigrationRequirement) -> (), onComplete: @escaping (Swift.Result, Bool) -> () ) { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { @@ -232,13 +236,24 @@ open class Storage { onProgressUpdate?(totalProgress, totalMinExpectedDuration) } }) + self.migrationRequirementProcesser = Atomic(onMigrationRequirement) // Store the logic to run when the migration completes let migrationCompleted: (Swift.Result) -> () = { [weak self] result in + // Process any unprocessed requirements which need to be processed before completion + // then clear out the state + self?.unprocessedMigrationRequirements.wrappedValue + .filter { $0.shouldProcessAtCompletionIfNotRequired } + .forEach { self?.migrationRequirementProcesser?.wrappedValue(nil, $0) } self?.migrationsCompleted.mutate { $0 = true } self?.migrationProgressUpdater = nil + self?.migrationRequirementProcesser = nil SUKLegacy.clearLegacyDatabaseInstance() + // Reset in case there is a requirement on a migration which runs when returning from + // the background + self?.unprocessedMigrationRequirements.mutate { $0 = MigrationRequirement.allCases } + // Don't log anything in the case of a 'success' or if the database is suspended (the // latter will happen if the user happens to return to the background too quickly on // launch so is unnecessarily alarming, it also gets caught and logged separately by @@ -288,6 +303,22 @@ open class Storage { } } + public func willStartMigration(_ db: Database, _ migration: Migration.Type) { + let unprocessedRequirements: Set = migration.requirements.asSet() + .intersection(unprocessedMigrationRequirements.wrappedValue.asSet()) + + // No need to do anything if there are no unprocessed requirements + guard !unprocessedRequirements.isEmpty else { return } + + // Process all of the requirements for this migration + unprocessedRequirements.forEach { migrationRequirementProcesser?.wrappedValue(db, $0) } + + // Remove any processed requirements from the list (don't want to process them multiple times) + unprocessedMigrationRequirements.mutate { + $0 = Array($0.asSet().subtracting(migration.requirements.asSet())) + } + } + public static func update( progress: CGFloat, for migration: Migration.Type, diff --git a/SessionUtilitiesKit/Database/Types/Migration.swift b/SessionUtilitiesKit/Database/Types/Migration.swift index 6e4c909e5..aa8a815de 100644 --- a/SessionUtilitiesKit/Database/Types/Migration.swift +++ b/SessionUtilitiesKit/Database/Types/Migration.swift @@ -8,17 +8,21 @@ public protocol Migration { static var identifier: String { get } static var needsConfigSync: Bool { get } static var minExpectedRunDuration: TimeInterval { get } + static var requirements: [MigrationRequirement] { get } static func migrate(_ db: Database) throws } public extension Migration { + static var requirements: [MigrationRequirement] { [] } + static func loggedMigrate( _ storage: Storage?, targetIdentifier: TargetMigrations.Identifier ) -> ((_ db: Database) throws -> ()) { return { (db: Database) in SNLogNotTests("[Migration Info] Starting \(targetIdentifier.key(with: self))") + storage?.willStartMigration(db, self) storage?.internalCurrentlyRunningMigration.mutate { $0 = (targetIdentifier, self) } defer { storage?.internalCurrentlyRunningMigration.mutate { $0 = nil } } diff --git a/SessionUtilitiesKit/Database/Types/MigrationRequirement.swift b/SessionUtilitiesKit/Database/Types/MigrationRequirement.swift new file mode 100644 index 000000000..9e586b809 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/MigrationRequirement.swift @@ -0,0 +1,12 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +import Foundation + +public enum MigrationRequirement: CaseIterable { + case sessionUtilStateLoaded + + var shouldProcessAtCompletionIfNotRequired: Bool { + switch self { + case .sessionUtilStateLoaded: return true + } + } +} diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 4ac031c56..75b708cba 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -82,6 +82,20 @@ public enum AppSetup { SNUIKit.self ], onProgressUpdate: migrationProgressChanged, + onMigrationRequirement: { db, requirement in + switch requirement { + case .sessionUtilStateLoaded: + guard Identity.userExists(db) else { return } + + // After the migrations have run but before the migration completion we load the + // SessionUtil state + SessionUtil.loadState( + db, + userPublicKey: getUserHexEncodedPublicKey(db), + ed25519SecretKey: Identity.fetchUserEd25519KeyPair(db)?.secretKey + ) + } + }, onComplete: { result, needsConfigSync in // After the migrations have run but before the migration completion we load the // SessionUtil state and update the 'needsConfigSync' flag based on whether the @@ -93,6 +107,8 @@ public enum AppSetup { ) } + // The 'needsConfigSync' flag should be based on whether either a migration or the + // configs need to be sync'ed migrationsCompletion(result, (needsConfigSync || SessionUtil.needsSync)) // The 'if' is only there to prevent the "variable never read" warning from showing From 382b466ded1a862d63edde44dcff76d8a1e4f6ba Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 11 Aug 2023 18:58:04 +1000 Subject: [PATCH 42/50] Fixed a bug where conversations without messages could display invalid dates --- Session/Utilities/Date+Utilities.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Session/Utilities/Date+Utilities.swift b/Session/Utilities/Date+Utilities.swift index ed7aab4f4..c6e440f3f 100644 --- a/Session/Utilities/Date+Utilities.swift +++ b/Session/Utilities/Date+Utilities.swift @@ -5,6 +5,9 @@ import SessionUtilitiesKit public extension Date { var formattedForDisplay: String { + // If we don't have a date then + guard self.timeIntervalSince1970 > 0 else { return "" } + let dateNow: Date = Date() guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .year) else { From 42853a08c9b5cb378de06f3fec5c0d68a133ee2d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 17 Aug 2023 11:28:03 +1000 Subject: [PATCH 43/50] Fixed a couple of minor outgoing quote bugs Added a nicer mechanism for using table aliases Fixed a bug where the quote preview view close button could overlap the content Fixed a bug where an outgoing quote wouldn't show it's thumbnail in some cases Fixed a bug where quoting a link preview wouldn't show the link preview attachment --- Session.xcodeproj/project.pbxproj | 4 +++ .../Content Views/QuoteView.swift | 7 ++--- .../Database/Models/Attachment.swift | 4 +-- .../Database/Models/Interaction.swift | 9 +++--- .../Jobs/Types/GarbageCollectionJob.swift | 2 +- .../Shared Models/MessageViewModel.swift | 30 +++++++++++++------ .../SessionThreadViewModel.swift | 2 +- .../Database/Types/TypedTableAlias.swift | 10 +++++-- .../SQLInterpolation+Utilities.swift | 21 +++++++++++++ 9 files changed, 65 insertions(+), 24 deletions(-) create mode 100644 SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a83905cf0..a8954a3b9 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -635,6 +635,7 @@ FD52090928B59411006098F6 /* ScreenLockUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090828B59411006098F6 /* ScreenLockUI.swift */; }; FD52090B28B59BB4006098F6 /* ScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090A28B59BB4006098F6 /* ScreenLockViewController.swift */; }; FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */; }; + FD5931A72A8DA5DA0040147D /* SQLInterpolation+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */; }; FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */; }; FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */; }; FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */; }; @@ -1755,6 +1756,7 @@ FD52090828B59411006098F6 /* ScreenLockUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockUI.swift; sourceTree = ""; }; FD52090A28B59BB4006098F6 /* ScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockViewController.swift; sourceTree = ""; }; FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Utilities.swift"; sourceTree = ""; }; + FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SQLInterpolation+Utilities.swift"; sourceTree = ""; }; FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ReadReceipts.swift"; sourceTree = ""; }; FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+TypingIndicators.swift"; sourceTree = ""; }; FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+DataExtractionNotification.swift"; sourceTree = ""; }; @@ -3693,6 +3695,7 @@ FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */, FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */, FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */, + FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -5666,6 +5669,7 @@ C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, FD71160228C8255900B47552 /* UIControl+Combine.swift in Sources */, FDFBB74B2A1EFF4900CA7350 /* Bencode.swift in Sources */, + FD5931A72A8DA5DA0040147D /* SQLInterpolation+Utilities.swift in Sources */, FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, FDF8487929405906007DCAE5 /* HTTPQueryParam.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index bda79ae51..ea86a406c 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -156,7 +156,7 @@ final class QuoteView: UIView { if attachment.isVisualMedia { attachment.thumbnail( size: .small, - success: { image, _ in + success: { [imageView] image, _ in guard Thread.isMainThread else { DispatchQueue.main.async { imageView.image = image @@ -234,8 +234,6 @@ final class QuoteView: UIView { } // Label stack view - let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace) - let isCurrentUser: Bool = [ currentUserPublicKey, currentUserBlinded15PublicKey, @@ -288,9 +286,8 @@ final class QuoteView: UIView { cancelButton.set(.height, to: cancelButtonSize) cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) - addSubview(cancelButton) + mainStackView.addArrangedSubview(cancelButton) cancelButton.center(.vertical, in: self) - cancelButton.pin(.right, to: .right, of: self) } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index bedc9fc31..9c1733e6c 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -522,7 +522,7 @@ extension Attachment { \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND - \(Interaction.linkPreviewFilterLiteral) + \(Interaction.linkPreviewFilterLiteral()) ) ) @@ -568,7 +568,7 @@ extension Attachment { \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND - \(Interaction.linkPreviewFilterLiteral) + \(Interaction.linkPreviewFilterLiteral()) ) ) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index c50645ca0..ffecf09bd 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -29,13 +29,14 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// Whenever using this `linkPreview` association make sure to filter the result using /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) - public static var linkPreviewFilterLiteral: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() + public static func linkPreviewFilterLiteral( + interaction: TypedTableAlias = TypedTableAlias(), + linkPreview: TypedTableAlias = TypedTableAlias() + ) -> SQL { let halfResolution: Double = LinkPreview.timstampResolution return "(\(interaction[.timestampMs]) BETWEEN (\(linkPreview[.timestamp]) - \(halfResolution)) * 1000 AND (\(linkPreview[.timestamp]) + \(halfResolution)) * 1000)" - }() + } public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 7b5989579..96c28cf99 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -143,7 +143,7 @@ public enum GarbageCollectionJob: JobExecutor { FROM \(LinkPreview.self) LEFT JOIN \(Interaction.self) ON ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND - \(Interaction.linkPreviewFilterLiteral) + \(Interaction.linkPreviewFilterLiteral()) ) WHERE \(interaction[.id]) IS NULL ) diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 2a796cbd1..c4aeef7a6 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -777,10 +777,11 @@ public extension MessageViewModel { let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() + let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") + let quoteLinkPreview: TypedTableAlias = TypedTableAlias(name: "quoteLinkPreview") let linkPreview: TypedTableAlias = TypedTableAlias() let threadProfile: SQL = SQL(stringLiteral: "threadProfile") - let quoteInteraction: SQL = SQL(stringLiteral: "quoteInteraction") let quoteInteractionAttachment: SQL = SQL(stringLiteral: "quoteInteractionAttachment") let readReceipt: SQL = SQL(stringLiteral: "readReceipt") let idColumn: SQL = SQL(stringLiteral: Interaction.Columns.id.name) @@ -845,7 +846,7 @@ public extension MessageViewModel { \(quote[.interactionId]), \(quote[.authorId]), \(quote[.timestampMs]), - \(quoteInteraction).\(interactionBodyColumn) AS \(quoteBodyColumn), + \(quoteInteraction[.body]), \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) AS \(quoteAttachmentIdColumn), \(ViewModel.quoteAttachmentKey).*, \(ViewModel.linkPreviewKey).*, @@ -873,12 +874,12 @@ public extension MessageViewModel { LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(Interaction.self) AS \(quoteInteraction) ON ( - \(quoteInteraction).\(timestampMsColumn) = \(quote[.timestampMs]) AND ( - \(quoteInteraction).\(authorIdColumn) = \(quote[.authorId]) OR ( + LEFT JOIN \(quoteInteraction) ON ( + \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( + \(quoteInteraction[.authorId]) = \(quote[.authorId]) OR ( -- A users outgoing message is stored in some cases using their standard id -- but the quote will use their blinded id so handle that case - \(quoteInteraction).\(authorIdColumn) = \(userPublicKey) AND + \(quoteInteraction[.authorId]) = \(userPublicKey) AND ( \(quote[.authorId]) = \(blinded15PublicKey ?? "''") OR \(quote[.authorId]) = \(blinded25PublicKey ?? "''") @@ -887,14 +888,25 @@ public extension MessageViewModel { ) ) LEFT JOIN \(InteractionAttachment.self) AS \(quoteInteractionAttachment) ON ( - \(quoteInteractionAttachment).\(interactionAttachmentInteractionIdColumn) = \(quoteInteraction).\(idColumn) AND + \(quoteInteractionAttachment).\(interactionAttachmentInteractionIdColumn) = \(quoteInteraction[.id]) AND \(quoteInteractionAttachment).\(interactionAttachmentAlbumIndexColumn) = 0 ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) + LEFT JOIN \(quoteLinkPreview) ON ( + \(quoteLinkPreview[.url]) = \(quoteInteraction[.linkPreviewUrl]) AND + \(Interaction.linkPreviewFilterLiteral( + interaction: quoteInteraction, + linkPreview: quoteLinkPreview + )) + ) + LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON ( + \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) OR + \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quoteLinkPreview[.attachmentId]) OR + \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quote[.attachmentId]) + ) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral) + \(Interaction.linkPreviewFilterLiteral()) ) LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumn) = \(linkPreview[.attachmentId]) LEFT JOIN \(RecipientState.self) ON ( diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index c9d0c43eb..de2548745 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -712,7 +712,7 @@ public extension SessionThreadViewModel { ) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral) AND + \(Interaction.linkPreviewFilterLiteral()) AND \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) ) LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( diff --git a/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift index 14dd0aacd..fa91388c4 100644 --- a/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift +++ b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift @@ -4,9 +4,15 @@ import Foundation import GRDB public class TypedTableAlias where T: TableRecord, T: ColumnExpressible { - public let alias: TableAlias = TableAlias(name: T.databaseTableName) + internal let name: String + internal let tableName: String + public let alias: TableAlias - public init() {} + public init(name: String = T.databaseTableName) { + self.name = name + self.tableName = T.databaseTableName + self.alias = TableAlias(name: name) + } public subscript(_ column: T.Columns) -> SQLExpression { return alias[column.name] diff --git a/SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift new file mode 100644 index 000000000..d8918ef17 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift @@ -0,0 +1,21 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension SQLInterpolation { + /// Appends the table name of the record type. + /// + /// // SELECT * FROM player + /// let player: TypedTableAlias = TypedTableAlias() + /// let request: SQLRequest = "SELECT * FROM \(player)" + @_disfavoredOverload + mutating func appendInterpolation(_ typedTableAlias: TypedTableAlias) { + let name: String = typedTableAlias.name + let tableName: String = typedTableAlias.tableName + + guard name != tableName else { return appendLiteral(tableName.quotedDatabaseIdentifier) } + + appendLiteral("\(tableName.quotedDatabaseIdentifier) AS \(name.quotedDatabaseIdentifier)") + } +} From e6c26e7ff4f4a24c3a3f9a766922859820c2c61d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 17 Aug 2023 16:39:47 +1000 Subject: [PATCH 44/50] Cleaned up the GRDB interface for complex queries --- Session.xcodeproj/project.pbxproj | 4 + .../GlobalSearchViewController.swift | 5 + .../MediaGalleryViewModel.swift | 42 +- .../Settings/BlockedContactsViewModel.swift | 21 +- .../Database/Models/ClosedGroup.swift | 13 + .../Database/Models/Interaction.swift | 20 +- .../Database/Models/OpenGroup.swift | 13 + .../Database/Models/Profile.swift | 15 + .../Database/Models/SessionThread.swift | 2 +- .../Jobs/Types/GarbageCollectionJob.swift | 22 +- .../Shared Models/MentionInfo.swift | 28 +- .../Shared Models/MessageViewModel.swift | 281 +++--- .../SessionThreadViewModel.swift | 936 +++++++++--------- .../Types/PagedDatabaseObserver.swift | 28 +- .../Database/Types/TypedTableAlias.swift | 59 +- .../SQLInterpolation+Utilities.swift | 50 +- .../Utilities/ScopeAdapter+Utilities.swift | 13 + 17 files changed, 877 insertions(+), 675 deletions(-) create mode 100644 SessionUtilitiesKit/Database/Utilities/ScopeAdapter+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a8954a3b9..d720d5f32 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -636,6 +636,7 @@ FD52090B28B59BB4006098F6 /* ScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090A28B59BB4006098F6 /* ScreenLockViewController.swift */; }; FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */; }; FD5931A72A8DA5DA0040147D /* SQLInterpolation+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */; }; + FD5931AB2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5931AA2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift */; }; FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */; }; FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */; }; FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */; }; @@ -1757,6 +1758,7 @@ FD52090A28B59BB4006098F6 /* ScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockViewController.swift; sourceTree = ""; }; FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Utilities.swift"; sourceTree = ""; }; FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SQLInterpolation+Utilities.swift"; sourceTree = ""; }; + FD5931AA2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScopeAdapter+Utilities.swift"; sourceTree = ""; }; FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+ReadReceipts.swift"; sourceTree = ""; }; FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+TypingIndicators.swift"; sourceTree = ""; }; FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+DataExtractionNotification.swift"; sourceTree = ""; }; @@ -3695,6 +3697,7 @@ FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */, FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */, FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */, + FD5931AA2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift */, FD5931A62A8DA5DA0040147D /* SQLInterpolation+Utilities.swift */, ); path = Utilities; @@ -5681,6 +5684,7 @@ C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */, FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */, + FD5931AB2A8DCB0A0040147D /* ScopeAdapter+Utilities.swift in Sources */, FD37E9FF28A5F2CD003AE748 /* Configuration.swift in Sources */, FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */, C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */, diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 7d4103a85..fdb8ca753 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -203,6 +203,11 @@ class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, U ]) } catch { + // Don't log the 'interrupt' error as that's just the user typing too fast + if (error as? DatabaseError)?.resultCode != DatabaseError.SQLITE_INTERRUPT { + SNLog("[GlobalSearch] Failed to find results due to error: \(error)") + } + return .failure(error) } } diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 10eff35e3..0ff182537 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -199,16 +199,18 @@ public class MediaGalleryViewModel { } } - public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable { - fileprivate static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) - fileprivate static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) - fileprivate static let interactionAuthorIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionAuthorId.stringValue) - fileprivate static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) - fileprivate static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - fileprivate static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) - fileprivate static let attachmentAlbumIndexKey: SQL = SQL(stringLiteral: CodingKeys.attachmentAlbumIndex.stringValue) - - fileprivate static let attachmentString: String = CodingKeys.attachment.stringValue + public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case interactionId + case interactionVariant + case interactionAuthorId + case interactionTimestampMs + + case rowId + case attachmentAlbumIndex + case attachment + } public var id: String { attachment.id } public var differenceIdentifier: String { attachment.id } @@ -306,7 +308,7 @@ public class MediaGalleryViewModel { let finalFilterSQL: SQL = { guard let customFilters: SQL = customFilters else { return """ - WHERE \(attachment.alias[Column.rowID]) IN \(rowIds) + WHERE \(attachment[.rowId]) IN \(rowIds) """ } @@ -318,14 +320,14 @@ public class MediaGalleryViewModel { }() let request: SQLRequest = """ SELECT - \(interaction[.id]) AS \(Item.interactionIdKey), - \(interaction[.variant]) AS \(Item.interactionVariantKey), - \(interaction[.authorId]) AS \(Item.interactionAuthorIdKey), - \(interaction[.timestampMs]) AS \(Item.interactionTimestampMsKey), + \(interaction[.id]) AS \(Item.Columns.interactionId), + \(interaction[.variant]) AS \(Item.Columns.interactionVariant), + \(interaction[.authorId]) AS \(Item.Columns.interactionAuthorId), + \(interaction[.timestampMs]) AS \(Item.Columns.interactionTimestampMs), - \(attachment.alias[Column.rowID]) AS \(Item.rowIdKey), - \(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey), - \(Item.attachmentKey).* + \(attachment[.rowId]) AS \(Item.Columns.rowId), + \(interactionAttachment[.albumIndex]) AS \(Item.Columns.attachmentAlbumIndex), + \(attachment.allColumns) FROM \(Attachment.self) \(joinSQL) \(finalFilterSQL) @@ -338,8 +340,8 @@ public class MediaGalleryViewModel { Attachment.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - Item.attachmentString: adapters[1] + return ScopeAdapter.with(Item.self, [ + .attachment: adapters[1] ]) } } diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index bf4a46bea..0a612d0b0 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -258,11 +258,12 @@ class BlockedContactsViewModel: SessionTableViewModel = """ SELECT - \(profile.alias[Column.rowID]) AS \(DataModel.rowIdKey), - \(DataModel.profileKey).* + \(profile[.rowId]) AS \(DataModel.Columns.rowId), + \(profile.allColumns) FROM \(Profile.self) - WHERE \(profile.alias[Column.rowID]) IN \(rowIds) + WHERE \(profile[.rowId]) IN \(rowIds) ORDER BY \(orderSQL) """ @@ -300,8 +301,8 @@ class BlockedContactsViewModel: SessionTableViewModel SQLRequest { let interaction: TypedTableAlias = TypedTableAlias() - let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) - let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) + let interactionFullTextSearch: TypedTableAlias = TypedTableAlias(name: Interaction.fullTextSearchTableName) let request: SQLRequest = """ SELECT @@ -720,9 +730,9 @@ public extension Interaction { \(interaction[.timestampMs]) FROM \(Interaction.self) JOIN \(interactionFullTextSearch) ON ( - \(interactionFullTextSearch).rowid = \(interaction.alias[Column.rowID]) AND - \(SQL("\(interactionFullTextSearch).\(threadIdLiteral) = \(threadId)")) AND - \(interactionFullTextSearch).\(SQL(stringLiteral: Interaction.Columns.body.name)) MATCH \(pattern) + \(interactionFullTextSearch[.rowId]) = \(interaction[.rowId]) AND + \(SQL("\(interactionFullTextSearch[.threadId]) = \(threadId)")) AND + \(interactionFullTextSearch[.body]) MATCH \(pattern) ) ORDER BY \(interaction[.timestampMs].desc) diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 55da87c1d..d4b27a35c 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -215,6 +215,19 @@ public extension OpenGroup { } } +// MARK: - Search Queries + +public extension OpenGroup { + struct FullTextSearch: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case name + } + + let name: String + } +} + // MARK: - Convenience public extension OpenGroup { diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index a9a6bd8af..7b8695929 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -298,6 +298,21 @@ public extension Profile { } } +// MARK: - Search Queries + +public extension Profile { + struct FullTextSearch: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case nickname + case name + } + + let nickname: String? + let name: String + } +} + // MARK: - Convenience public extension Profile { diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 163d38038..cb8f84fc3 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -365,7 +365,7 @@ public extension SessionThread { let contact: TypedTableAlias = TypedTableAlias() return """ - SELECT \(thread.allColumns()) + SELECT \(thread.allColumns) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) WHERE ( diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 96c28cf99..633588a3d 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -82,7 +82,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Interaction.self) WHERE \(Column.rowID) IN ( - SELECT \(interaction.alias[Column.rowID]) + SELECT \(interaction[.rowId]) FROM \(Interaction.self) JOIN \(SessionThread.self) ON ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND @@ -90,7 +90,7 @@ public enum GarbageCollectionJob: JobExecutor { ) JOIN ( SELECT - COUNT(\(interaction.alias[Column.rowID])) AS interactionCount, + COUNT(\(interaction[.rowId])) AS interactionCount, \(interaction[.threadId]) FROM \(Interaction.self) GROUP BY \(interaction[.threadId]) @@ -112,7 +112,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Job.self) WHERE \(Column.rowID) IN ( - SELECT \(job.alias[Column.rowID]) + SELECT \(job[.rowId]) FROM \(Job.self) LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(job[.threadId]) LEFT JOIN \(Interaction.self) ON \(interaction[.id]) = \(job[.interactionId]) @@ -139,7 +139,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(LinkPreview.self) WHERE \(Column.rowID) IN ( - SELECT \(linkPreview.alias[Column.rowID]) + SELECT \(linkPreview[.rowId]) FROM \(LinkPreview.self) LEFT JOIN \(Interaction.self) ON ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND @@ -159,7 +159,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(OpenGroup.self) WHERE \(Column.rowID) IN ( - SELECT \(openGroup.alias[Column.rowID]) + SELECT \(openGroup[.rowId]) FROM \(OpenGroup.self) LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(openGroup[.threadId]) WHERE ( @@ -178,7 +178,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Capability.self) WHERE \(Column.rowID) IN ( - SELECT \(capability.alias[Column.rowID]) + SELECT \(capability[.rowId]) FROM \(Capability.self) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.server]) = \(capability[.openGroupServer]) WHERE \(openGroup[.threadId]) IS NULL @@ -195,7 +195,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(BlindedIdLookup.self) WHERE \(Column.rowID) IN ( - SELECT \(blindedIdLookup.alias[Column.rowID]) + SELECT \(blindedIdLookup[.rowId]) FROM \(BlindedIdLookup.self) LEFT JOIN \(SessionThread.self) ON ( \(thread[.id]) = \(blindedIdLookup[.blindedId]) OR @@ -222,7 +222,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Contact.self) WHERE \(Column.rowID) IN ( - SELECT \(contact.alias[Column.rowID]) + SELECT \(contact[.rowId]) FROM \(Contact.self) LEFT JOIN \(BlindedIdLookup.self) ON ( \(blindedIdLookup[.blindedId]) = \(contact[.id]) AND @@ -243,7 +243,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Attachment.self) WHERE \(Column.rowID) IN ( - SELECT \(attachment.alias[Column.rowID]) + SELECT \(attachment[.rowId]) FROM \(Attachment.self) LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id]) LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id]) @@ -269,7 +269,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(Profile.self) WHERE \(Column.rowID) IN ( - SELECT \(profile.alias[Column.rowID]) + SELECT \(profile[.rowId]) FROM \(Profile.self) LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(profile[.id]) LEFT JOIN \(Interaction.self) ON \(interaction[.authorId]) = \(profile[.id]) @@ -310,7 +310,7 @@ public enum GarbageCollectionJob: JobExecutor { try db.execute(literal: """ DELETE FROM \(SessionThread.self) WHERE \(Column.rowID) IN ( - SELECT \(thread.alias[Column.rowID]) + SELECT \(thread[.rowId]) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) diff --git a/SessionMessagingKit/Shared Models/MentionInfo.swift b/SessionMessagingKit/Shared Models/MentionInfo.swift index 984ddf63d..f4bb049cb 100644 --- a/SessionMessagingKit/Shared Models/MentionInfo.swift +++ b/SessionMessagingKit/Shared Models/MentionInfo.swift @@ -3,12 +3,14 @@ import GRDB import SessionUtilitiesKit -public struct MentionInfo: FetchableRecord, Decodable { - fileprivate static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) - fileprivate static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue) - fileprivate static let openGroupRoomTokenKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoomToken.stringValue) - - fileprivate static let profileString: String = CodingKeys.profile.stringValue +public struct MentionInfo: FetchableRecord, Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case profile + case threadVariant + case openGroupServer + case openGroupRoomToken + } public let profile: Profile public let threadVariant: SessionThread.Variant @@ -79,7 +81,7 @@ public extension MentionInfo { return SQLRequest(""" SELECT \(Profile.self).*, - \(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")) + \(SQL("\(threadVariant) AS \(MentionInfo.Columns.threadVariant)")) \(targetJoin) \(targetWhere) AND \(SQL("\(profile[.id]) = \(threadId)")) @@ -89,7 +91,7 @@ public extension MentionInfo { return SQLRequest(""" SELECT \(Profile.self).*, - \(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")) + \(SQL("\(threadVariant) AS \(MentionInfo.Columns.threadVariant)")) \(targetJoin) JOIN \(GroupMember.self) ON ( @@ -107,9 +109,9 @@ public extension MentionInfo { SELECT \(Profile.self).*, MAX(\(interaction[.timestampMs])), -- Want the newest interaction (for sorting) - \(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")), - \(openGroup[.server]) AS \(MentionInfo.openGroupServerKey), - \(openGroup[.roomToken]) AS \(MentionInfo.openGroupRoomTokenKey) + \(SQL("\(threadVariant) AS \(MentionInfo.Columns.threadVariant)")), + \(openGroup[.server]) AS \(MentionInfo.Columns.openGroupServer), + \(openGroup[.roomToken]) AS \(MentionInfo.Columns.openGroupRoomToken) \(targetJoin) JOIN \(Interaction.self) ON ( @@ -130,8 +132,8 @@ public extension MentionInfo { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - MentionInfo.profileString: adapters[0] + return ScopeAdapter.with(MentionInfo.self, [ + .profile: adapters[0] ]) } } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index c4aeef7a6..3eaada1b1 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -11,42 +11,66 @@ fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInt fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo -public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { - public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) - public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) - public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue) - public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue) - public static let threadOpenGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupServer.stringValue) - public static let threadOpenGroupPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.threadOpenGroupPublicKey.stringValue) - public static let threadContactNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.threadContactNameInternal.stringValue) - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) - public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue) - public static let hasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.hasAtLeastOneReadReceipt.stringValue) - public static let mostRecentFailureTextKey: SQL = SQL(stringLiteral: CodingKeys.mostRecentFailureText.stringValue) - public static let isTypingIndicatorKey: SQL = SQL(stringLiteral: CodingKeys.isTypingIndicator.stringValue) - public static let isSenderOpenGroupModeratorKey: SQL = SQL(stringLiteral: CodingKeys.isSenderOpenGroupModerator.stringValue) - public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) - public static let quoteKey: SQL = SQL(stringLiteral: CodingKeys.quote.stringValue) - public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue) - public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue) - public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue) - public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) - public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue) - public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue) - public static let canHaveProfileKey: SQL = SQL(stringLiteral: CodingKeys.canHaveProfile.stringValue) - public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue) - public static let shouldShowDateHeaderKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowDateHeader.stringValue) - public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue) - public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue) - public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue) - public static let isLastOutgoingKey: SQL = SQL(stringLiteral: CodingKeys.isLastOutgoing.stringValue) - - public static let profileString: String = CodingKeys.profile.stringValue - public static let quoteString: String = CodingKeys.quote.stringValue - public static let quoteAttachmentString: String = CodingKeys.quoteAttachment.stringValue - public static let linkPreviewString: String = CodingKeys.linkPreview.stringValue - public static let linkPreviewAttachmentString: String = CodingKeys.linkPreviewAttachment.stringValue +public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case threadId + case threadVariant + case threadIsTrusted + case threadHasDisappearingMessagesEnabled + case threadOpenGroupServer + case threadOpenGroupPublicKey + case threadContactNameInternal + + // Interaction Info + + case rowId + case id + case openGroupServerMessageId + case variant + case timestampMs + case receivedAtTimestampMs + case authorId + case authorNameInternal + case body + case rawBody + case expiresStartedAtMs + case expiresInSeconds + + case state + case hasAtLeastOneReadReceipt + case mostRecentFailureText + case isSenderOpenGroupModerator + case isTypingIndicator + case profile + case quote + case quoteAttachment + case linkPreview + case linkPreviewAttachment + + case currentUserPublicKey + + // Post-Query Processing Data + + case attachments + case reactionInfo + case cellType + case authorName + case senderName + case canHaveProfile + case shouldShowProfile + case shouldShowDateHeader + case containsOnlyEmoji + case glyphCount + case previousVariant + case positionInCluster + case isOnlyMessageInCluster + case isLast + case isLastOutgoing + case currentUserBlinded15PublicKey + case currentUserBlinded25PublicKey + case optimisticMessageId + } public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { case textOnlyMessage @@ -462,13 +486,13 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, // MARK: - AttachmentInteractionInfo public extension MessageViewModel { - struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable { - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue) - public static let interactionAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachment.stringValue) - - public static let attachmentString: String = CodingKeys.attachment.stringValue - public static let interactionAttachmentString: String = CodingKeys.interactionAttachment.stringValue + struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rowId + case attachment + case interactionAttachment + } public let rowId: Int64 public let attachment: Attachment @@ -491,13 +515,13 @@ public extension MessageViewModel { // MARK: - ReactionInfo public extension MessageViewModel { - struct ReactionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, Hashable, Differentiable { - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let reactionKey: SQL = SQL(stringLiteral: CodingKeys.reaction.stringValue) - public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue) - - public static let reactionString: String = CodingKeys.reaction.stringValue - public static let profileString: String = CodingKeys.profile.stringValue + struct ReactionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, Hashable, Differentiable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rowId + case reaction + case profile + } public let rowId: Int64 public let reaction: Reaction @@ -522,9 +546,12 @@ public extension MessageViewModel { // MARK: - TypingIndicatorInfo public extension MessageViewModel { - struct TypingIndicatorInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable { - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) + struct TypingIndicatorInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rowId + case threadId + } public let rowId: Int64 public let threadId: String @@ -776,60 +803,48 @@ public extension MessageViewModel { let contact: TypedTableAlias = TypedTableAlias() let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() + let threadProfile: TypedTableAlias = TypedTableAlias(name: "threadProfile") let quote: TypedTableAlias = TypedTableAlias() let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") + let quoteInteractionAttachment: TypedTableAlias = TypedTableAlias( + name: "quoteInteractionAttachment" + ) let quoteLinkPreview: TypedTableAlias = TypedTableAlias(name: "quoteLinkPreview") + let quoteAttachment: TypedTableAlias = TypedTableAlias(name: ViewModel.CodingKeys.quoteAttachment.stringValue) let linkPreview: TypedTableAlias = TypedTableAlias() - - let threadProfile: SQL = SQL(stringLiteral: "threadProfile") - let quoteInteractionAttachment: SQL = SQL(stringLiteral: "quoteInteractionAttachment") - let readReceipt: SQL = SQL(stringLiteral: "readReceipt") - let idColumn: SQL = SQL(stringLiteral: Interaction.Columns.id.name) - let interactionBodyColumn: SQL = SQL(stringLiteral: Interaction.Columns.body.name) - let profileIdColumn: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let nicknameColumn: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) - let nameColumn: SQL = SQL(stringLiteral: Profile.Columns.name.name) - let quoteBodyColumn: SQL = SQL(stringLiteral: Quote.Columns.body.name) - let quoteAttachmentIdColumn: SQL = SQL(stringLiteral: Quote.Columns.attachmentId.name) - let readReceiptInteractionIdColumn: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let readTimestampMsColumn: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) - let timestampMsColumn: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) - let authorIdColumn: SQL = SQL(stringLiteral: Interaction.Columns.authorId.name) - let attachmentIdColumn: SQL = SQL(stringLiteral: Attachment.Columns.id.name) - let interactionAttachmentInteractionIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) - let interactionAttachmentAttachmentIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) - let interactionAttachmentAlbumIndexColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) + let linkPreviewAttachment: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .linkPreviewAttachment) + let readReceipt: TypedTableAlias = TypedTableAlias(name: "readReceipt") let numColumnsBeforeLinkedRecords: Int = 22 let finalGroupSQL: SQL = (groupSQL ?? "") let request: SQLRequest = """ SELECT - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), -- Default to 'true' for non-contact threads - IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey), + IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.Columns.threadIsTrusted), -- Default to 'false' when no contact exists - IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), - \(openGroup[.server]) AS \(ViewModel.threadOpenGroupServerKey), - \(openGroup[.publicKey]) AS \(ViewModel.threadOpenGroupPublicKeyKey), - IFNULL(\(threadProfile).\(nicknameColumn), \(threadProfile).\(nameColumn)) AS \(ViewModel.threadContactNameInternalKey), + IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.Columns.threadHasDisappearingMessagesEnabled), + \(openGroup[.server]) AS \(ViewModel.Columns.threadOpenGroupServer), + \(openGroup[.publicKey]) AS \(ViewModel.Columns.threadOpenGroupPublicKey), + IFNULL(\(threadProfile[.nickname]), \(threadProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), - \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), + \(interaction[.rowId]) AS \(ViewModel.Columns.rowId), \(interaction[.id]), \(interaction[.openGroupServerMessageId]), \(interaction[.variant]), \(interaction[.timestampMs]), \(interaction[.receivedAtTimestampMs]), \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), \(interaction[.body]), \(interaction[.expiresStartedAtMs]), \(interaction[.expiresInSeconds]), -- Default to 'sending' assuming non-processed interaction when null - IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), - (\(readReceipt).\(readTimestampMsColumn) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), - \(recipientState[.mostRecentFailureText]) AS \(ViewModel.mostRecentFailureTextKey), + IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.Columns.state), + (\(readReceipt[.readTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.hasAtLeastOneReadReceipt), + \(recipientState[.mostRecentFailureText]) AS \(ViewModel.Columns.mostRecentFailureText), EXISTS ( SELECT 1 @@ -840,36 +855,36 @@ public extension MessageViewModel { \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(SQL("\(groupMember[.role]) IN \([GroupMember.Role.moderator, GroupMember.Role.admin])")) ) - ) AS \(ViewModel.isSenderOpenGroupModeratorKey), + ) AS \(ViewModel.Columns.isSenderOpenGroupModerator), - \(ViewModel.profileKey).*, + \(profile.allColumns), \(quote[.interactionId]), \(quote[.authorId]), \(quote[.timestampMs]), \(quoteInteraction[.body]), - \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) AS \(quoteAttachmentIdColumn), - \(ViewModel.quoteAttachmentKey).*, - \(ViewModel.linkPreviewKey).*, - \(ViewModel.linkPreviewAttachmentKey).*, + \(quoteInteractionAttachment[.attachmentId]), + \(quoteAttachment.allColumns), + \(linkPreview.allColumns), + \(linkPreviewAttachment.allColumns), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey), + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey), -- All of the below properties are set in post-query processing but to prevent the -- query from crashing when decoding we need to provide default values - \(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey), - '' AS \(ViewModel.authorNameKey), - false AS \(ViewModel.canHaveProfileKey), - false AS \(ViewModel.shouldShowProfileKey), - false AS \(ViewModel.shouldShowDateHeaderKey), - \(Position.middle) AS \(ViewModel.positionInClusterKey), - false AS \(ViewModel.isOnlyMessageInClusterKey), - false AS \(ViewModel.isLastKey), - false AS \(ViewModel.isLastOutgoingKey) + \(CellType.textOnlyMessage) AS \(ViewModel.Columns.cellType), + '' AS \(ViewModel.Columns.authorName), + false AS \(ViewModel.Columns.canHaveProfile), + false AS \(ViewModel.Columns.shouldShowProfile), + false AS \(ViewModel.Columns.shouldShowDateHeader), + \(Position.middle) AS \(ViewModel.Columns.positionInCluster), + false AS \(ViewModel.Columns.isOnlyMessageInCluster), + false AS \(ViewModel.Columns.isLast), + false AS \(ViewModel.Columns.isLastOutgoing) FROM \(Interaction.self) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) - LEFT JOIN \(Profile.self) AS \(threadProfile) ON \(threadProfile).\(profileIdColumn) = \(interaction[.threadId]) + LEFT JOIN \(threadProfile) ON \(threadProfile[.id]) = \(interaction[.threadId]) LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) @@ -887,9 +902,9 @@ public extension MessageViewModel { ) ) ) - LEFT JOIN \(InteractionAttachment.self) AS \(quoteInteractionAttachment) ON ( - \(quoteInteractionAttachment).\(interactionAttachmentInteractionIdColumn) = \(quoteInteraction[.id]) AND - \(quoteInteractionAttachment).\(interactionAttachmentAlbumIndexColumn) = 0 + LEFT JOIN \(quoteInteractionAttachment) ON ( + \(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND + \(quoteInteractionAttachment[.albumIndex]) = 0 ) LEFT JOIN \(quoteLinkPreview) ON ( \(quoteLinkPreview[.url]) = \(quoteInteraction[.linkPreviewUrl]) AND @@ -898,27 +913,27 @@ public extension MessageViewModel { linkPreview: quoteLinkPreview )) ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON ( - \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) OR - \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quoteLinkPreview[.attachmentId]) OR - \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quote[.attachmentId]) + LEFT JOIN \(quoteAttachment) ON ( + \(quoteAttachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR + \(quoteAttachment[.id]) = \(quoteLinkPreview[.attachmentId]) OR + \(quoteAttachment[.id]) = \(quote[.attachmentId]) ) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND \(Interaction.linkPreviewFilterLiteral()) ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumn) = \(linkPreview[.attachmentId]) + LEFT JOIN \(linkPreviewAttachment) ON \(linkPreviewAttachment[.id]) = \(linkPreview[.attachmentId]) LEFT JOIN \(RecipientState.self) ON ( -- Ignore 'skipped' states \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND \(recipientState[.interactionId]) = \(interaction[.id]) ) - LEFT JOIN \(RecipientState.self) AS \(readReceipt) ON ( - \(readReceipt).\(readTimestampMsColumn) IS NOT NULL AND - \(readReceipt).\(readReceiptInteractionIdColumn) = \(interaction[.id]) + LEFT JOIN \(readReceipt) ON ( + \(readReceipt[.readTimestampMs]) IS NOT NULL AND + \(readReceipt[.interactionId]) = \(interaction[.id]) ) - WHERE \(interaction.alias[Column.rowID]) IN \(rowIds) + WHERE \(interaction[.rowId]) IN \(rowIds) \(finalGroupSQL) ORDER BY \(orderSQL) """ @@ -933,12 +948,12 @@ public extension MessageViewModel { Attachment.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.profileString: adapters[1], - ViewModel.quoteString: adapters[2], - ViewModel.quoteAttachmentString: adapters[3], - ViewModel.linkPreviewString: adapters[4], - ViewModel.linkPreviewAttachmentString: adapters[5] + return ScopeAdapter.with(ViewModel.self, [ + .profile: adapters[1], + .quote: adapters[2], + .quoteAttachment: adapters[3], + .linkPreview: adapters[4], + .linkPreviewAttachment: adapters[5] ]) } } @@ -965,9 +980,9 @@ public extension MessageViewModel.AttachmentInteractionInfo { let numColumnsBeforeLinkedRecords: Int = 1 let request: SQLRequest = """ SELECT - \(attachment.alias[Column.rowID]) AS \(AttachmentInteractionInfo.rowIdKey), - \(AttachmentInteractionInfo.attachmentKey).*, - \(AttachmentInteractionInfo.interactionAttachmentKey).* + \(attachment[.rowId]) AS \(AttachmentInteractionInfo.Columns.rowId), + \(attachment.allColumns), + \(interactionAttachment.allColumns) FROM \(Attachment.self) JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) \(finalFilterSQL) @@ -980,9 +995,9 @@ public extension MessageViewModel.AttachmentInteractionInfo { InteractionAttachment.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - AttachmentInteractionInfo.attachmentString: adapters[1], - AttachmentInteractionInfo.interactionAttachmentString: adapters[2] + return ScopeAdapter.with(AttachmentInteractionInfo.self, [ + .attachment: adapters[1], + .interactionAttachment: adapters[2] ]) } } @@ -1046,9 +1061,9 @@ public extension MessageViewModel.ReactionInfo { let numColumnsBeforeLinkedRecords: Int = 1 let request: SQLRequest = """ SELECT - \(reaction.alias[Column.rowID]) AS \(ReactionInfo.rowIdKey), - \(ReactionInfo.reactionKey).*, - \(ReactionInfo.profileKey).* + \(reaction[.rowId]) AS \(ReactionInfo.Columns.rowId), + \(reaction.allColumns), + \(profile.allColumns) FROM \(Reaction.self) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(reaction[.authorId]) \(finalFilterSQL) @@ -1061,9 +1076,9 @@ public extension MessageViewModel.ReactionInfo { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ReactionInfo.reactionString: adapters[1], - ReactionInfo.profileString: adapters[2] + return ScopeAdapter.with(ReactionInfo.self, [ + .reaction: adapters[1], + .profile: adapters[2] ]) } } @@ -1129,8 +1144,8 @@ public extension MessageViewModel.TypingIndicatorInfo { }() let request: SQLRequest = """ SELECT - \(threadTypingIndicator.alias[Column.rowID]) AS \(MessageViewModel.TypingIndicatorInfo.rowIdKey), - \(threadTypingIndicator[.threadId]) AS \(MessageViewModel.TypingIndicatorInfo.threadIdKey) + \(threadTypingIndicator[.rowId]), + \(threadTypingIndicator[.threadId]) FROM \(ThreadTypingIndicator.self) \(finalFilterSQL) """ diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index de2548745..87a09f337 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -14,65 +14,70 @@ fileprivate typealias ViewModel = SessionThreadViewModel /// /// **Note:** When updating the UI make sure to check the actual queries being run as some fields will have incorrect default values /// in order to optimise their queries to only include the required data -public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable { - public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue) - public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) - public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) - public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue) - public static let threadMemberNamesKey: SQL = SQL(stringLiteral: CodingKeys.threadMemberNames.stringValue) - public static let threadIsNoteToSelfKey: SQL = SQL(stringLiteral: CodingKeys.threadIsNoteToSelf.stringValue) - public static let threadIsMessageRequestKey: SQL = SQL(stringLiteral: CodingKeys.threadIsMessageRequest.stringValue) - public static let threadRequiresApprovalKey: SQL = SQL(stringLiteral: CodingKeys.threadRequiresApproval.stringValue) - public static let threadShouldBeVisibleKey: SQL = SQL(stringLiteral: CodingKeys.threadShouldBeVisible.stringValue) - public static let threadPinnedPriorityKey: SQL = SQL(stringLiteral: CodingKeys.threadPinnedPriority.stringValue) - public static let threadIsBlockedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsBlocked.stringValue) - public static let threadMutedUntilTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadMutedUntilTimestamp.stringValue) - public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue) - public static let threadMessageDraftKey: SQL = SQL(stringLiteral: CodingKeys.threadMessageDraft.stringValue) - public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue) - public static let threadWasMarkedUnreadKey: SQL = SQL(stringLiteral: CodingKeys.threadWasMarkedUnread.stringValue) - public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) - public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) - public static let disappearingMessagesConfigurationKey: SQL = SQL(stringLiteral: CodingKeys.disappearingMessagesConfiguration.stringValue) - public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue) - public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue) - public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue) - public static let currentUserIsClosedGroupMemberKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupMember.stringValue) - public static let currentUserIsClosedGroupAdminKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupAdmin.stringValue) - public static let closedGroupProfileFrontKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileFront.stringValue) - public static let closedGroupProfileBackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBack.stringValue) - public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue) - public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue) - public static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue) - public static let openGroupRoomTokenKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoomToken.stringValue) - public static let openGroupPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.openGroupPublicKey.stringValue) - public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue) - public static let openGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.openGroupUserCount.stringValue) - public static let openGroupPermissionsKey: SQL = SQL(stringLiteral: CodingKeys.openGroupPermissions.stringValue) - public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue) - public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue) - public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue) - public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue) - public static let interactionStateKey: SQL = SQL(stringLiteral: CodingKeys.interactionState.stringValue) - public static let interactionHasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.interactionHasAtLeastOneReadReceipt.stringValue) - public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue) - public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue) - public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue) - public static let threadContactNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.threadContactNameInternal.stringValue) - public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) - public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) - - public static let threadWasMarkedUnreadString: String = CodingKeys.threadWasMarkedUnread.stringValue - public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue - public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue - public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue - public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue - public static let disappearingMessagesConfigurationString: String = CodingKeys.disappearingMessagesConfiguration.stringValue - public static let contactProfileString: String = CodingKeys.contactProfile.stringValue - public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue - public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue - public static let closedGroupProfileBackFallbackString: String = CodingKeys.closedGroupProfileBackFallback.stringValue - public static let interactionAttachmentDescriptionInfoString: String = CodingKeys.interactionAttachmentDescriptionInfo.stringValue +public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rowId + case threadId + case threadVariant + case threadCreationDateTimestamp + case threadMemberNames + + case threadIsNoteToSelf + case threadIsMessageRequest + case threadRequiresApproval + case threadShouldBeVisible + case threadPinnedPriority + case threadIsBlocked + case threadMutedUntilTimestamp + case threadOnlyNotifyForMentions + case threadMessageDraft + + case threadContactIsTyping + case threadWasMarkedUnread + case threadUnreadCount + case threadUnreadMentionCount + + // Thread display info + + case disappearingMessagesConfiguration + + case contactProfile + case closedGroupProfileFront + case closedGroupProfileBack + case closedGroupProfileBackFallback + case closedGroupName + case closedGroupUserCount + case currentUserIsClosedGroupMember + case currentUserIsClosedGroupAdmin + case openGroupName + case openGroupServer + case openGroupRoomToken + case openGroupPublicKey + case openGroupProfilePictureData + case openGroupUserCount + case openGroupPermissions + + // Interaction display info + + case interactionId + case interactionVariant + case interactionTimestampMs + case interactionBody + case interactionState + case interactionHasAtLeastOneReadReceipt + case interactionIsOpenGroupInvitation + case interactionAttachmentDescriptionInfo + case interactionAttachmentCount + + case authorId + case threadContactNameInternal + case authorNameInternal + case currentUserPublicKey + case currentUserBlinded15PublicKey + case currentUserBlinded25PublicKey + case recentReactionEmoji + } public var differenceIdentifier: String { threadId } public var id: String { threadId } @@ -554,6 +559,51 @@ public extension SessionThreadViewModel { } } +// MARK: - AggregateInteraction + +private struct AggregateInteraction: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case interactionId + case threadId + case interactionTimestampMs + case threadUnreadCount + case threadUnreadMentionCount + } + + let interactionId: Int64 + let threadId: String + let interactionTimestampMs: Int64 + let threadUnreadCount: UInt? + let threadUnreadMentionCount: UInt? +} + +// MARK: - ClosedGroupUserCount + +private struct ClosedGroupUserCount: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case groupId + case closedGroupUserCount + } + + let groupId: String + let closedGroupUserCount: Int +} + +// MARK: - GroupMemberInfo + +private struct GroupMemberInfo: Decodable, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case groupId + case threadMemberNames + } + + let groupId: String + let threadMemberNames: String +} + // MARK: - HomeVC & MessageRequestsViewController // MARK: --SessionThreadViewModel @@ -570,65 +620,57 @@ public extension SessionThreadViewModel { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let typingIndicator: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() + let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") let interaction: TypedTableAlias = TypedTableAlias() let recipientState: TypedTableAlias = TypedTableAlias() + let readReceipt: TypedTableAlias = TypedTableAlias(name: "readReceipt") let linkPreview: TypedTableAlias = TypedTableAlias() + let firstInteractionAttachment: TypedTableAlias = TypedTableAlias(name: "firstInteractionAttachment") let attachment: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() - - let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") - let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) - let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") - let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) - let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) - let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment") - let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) - let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) - let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) - + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) + let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 14 let numColumnsBetweenProfilesAndAttachmentInfo: Int = 12 // The attachment info columns will be combined let request: SQLRequest = """ SELECT - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), - \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND \(SQL("\(thread[.id]) != \(userPublicKey)")) AND IFNULL(\(contact[.isApproved]), false) = false - ) AS \(ViewModel.threadIsMessageRequestKey), + ) AS \(ViewModel.Columns.threadIsMessageRequest), - (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), - \(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey), - \(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey), - \(aggregateInteractionLiteral).\(ViewModel.threadUnreadMentionCountKey), + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.Columns.threadContactIsTyping), + \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), + \(aggregateInteraction[.threadUnreadCount]), + \(aggregateInteraction[.threadUnreadMentionCount]), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), EXISTS ( SELECT 1 @@ -638,7 +680,7 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), EXISTS ( SELECT 1 @@ -648,15 +690,15 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), - \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), - \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + \(interaction[.id]) AS \(ViewModel.Columns.interactionId), + \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), + \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), + \(interaction[.body]) AS \(ViewModel.Columns.interactionBody), -- Default to 'sending' assuming non-processed interaction when null IFNULL(( @@ -668,22 +710,22 @@ public extension SessionThreadViewModel { \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) ) LIMIT 1 - ), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.interactionStateKey), + ), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.Columns.interactionState), - (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.interactionHasAtLeastOneReadReceiptKey), - (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey), + (\(readReceipt[.readTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.interactionHasAtLeastOneReadReceipt), + (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.Columns.interactionIsOpenGroupInvitation), -- These 4 properties will be combined into 'Attachment.DescriptionInfo' \(attachment[.id]), \(attachment[.variant]), \(attachment[.contentType]), \(attachment[.sourceFilename]), - COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), + COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.Columns.interactionAttachmentCount), \(interaction[.authorId]), - IFNULL(\(ViewModel.contactProfileKey).\(profileNicknameColumnLiteral), \(ViewModel.contactProfileKey).\(profileNameColumnLiteral)) AS \(ViewModel.threadContactNameInternalKey), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + IFNULL(\(contactProfile[.nickname]), \(contactProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) @@ -691,46 +733,46 @@ public extension SessionThreadViewModel { LEFT JOIN ( SELECT - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.threadId]) AS \(ViewModel.threadIdKey), - MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral), - SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), - SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) + \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), + \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), + MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), + SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(AggregateInteraction.Columns.threadUnreadMentionCount) FROM \(Interaction.self) WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) - ) AS \(aggregateInteractionLiteral) ON \(aggregateInteractionLiteral).\(ViewModel.threadIdKey) = \(thread[.id]) + ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) LEFT JOIN \(Interaction.self) ON ( \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.id]) = \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey) + \(interaction[.id]) = \(aggregateInteraction[.interactionId]) ) - LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( - \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) AND - \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL + LEFT JOIN \(readReceipt) ON ( + \(interaction[.id]) = \(readReceipt[.interactionId]) AND + \(readReceipt[.readTimestampMs]) IS NOT NULL ) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND \(Interaction.linkPreviewFilterLiteral()) AND \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) ) - LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( - \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(interaction[.id]) AND - \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 + LEFT JOIN \(firstInteractionAttachment) ON ( + \(firstInteractionAttachment[.interactionId]) = \(interaction[.id]) AND + \(firstInteractionAttachment[.albumIndex]) = 0 ) - LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) + LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachment[.attachmentId]) LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) -- Thread naming & avatar content - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -741,9 +783,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -754,13 +796,13 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + LEFT JOIN \(closedGroupProfileBackFallback) ON ( \(closedGroup[.threadId]) IS NOT NULL AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userPublicKey)")) ) - WHERE \(thread.alias[Column.rowID]) IN \(rowIds) + WHERE \(thread[.rowId]) IN \(rowIds) \(groupSQL) ORDER BY \(orderSQL) """ @@ -776,12 +818,12 @@ public extension SessionThreadViewModel { Attachment.DescriptionInfo.numberOfSelectedColumns() ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4], - ViewModel.interactionAttachmentDescriptionInfoString: adapters[6] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4], + .interactionAttachmentDescriptionInfo: adapters[6] ]) } } @@ -868,55 +910,52 @@ public extension SessionThreadViewModel { let thread: TypedTableAlias = TypedTableAlias() let disappearingMessagesConfiguration: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() + let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") let interaction: TypedTableAlias = TypedTableAlias() - - let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") - let closedGroupUserCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.closedGroupUserCountString)_table") - let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let closedGroupUserCount: TypedTableAlias = TypedTableAlias(name: "closedGroupUserCount") /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `disappearingMessageSConfiguration` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 15 let request: SQLRequest = """ SELECT - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND \(SQL("\(thread[.id]) != \(userPublicKey)")) AND IFNULL(\(contact[.isApproved]), false) = false - ) AS \(ViewModel.threadIsMessageRequestKey), + ) AS \(ViewModel.Columns.threadIsMessageRequest), ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND IFNULL(\(contact[.didApproveMe]), false) = false - ) AS \(ViewModel.threadRequiresApprovalKey), - \(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey), + ) AS \(ViewModel.Columns.threadRequiresApproval), + \(thread[.shouldBeVisible]) AS \(ViewModel.Columns.threadShouldBeVisible), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), - \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), - \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), + \(thread[.messageDraft]) AS \(ViewModel.Columns.threadMessageDraft), - \(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey), - \(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey), + \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), + \(aggregateInteraction[.threadUnreadCount]), - \(ViewModel.disappearingMessagesConfigurationKey).*, + \(disappearingMessagesConfiguration.allColumns), - \(ViewModel.contactProfileKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(closedGroupUserCountTableLiteral).\(ViewModel.closedGroupUserCountKey) AS \(ViewModel.closedGroupUserCountKey), + \(contactProfile.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(closedGroupUserCount[.closedGroupUserCount]), EXISTS ( SELECT 1 @@ -926,49 +965,50 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), - \(openGroup[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey), - \(openGroup[.publicKey]) AS \(ViewModel.openGroupPublicKeyKey), - \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), - \(openGroup[.permissions]) AS \(ViewModel.openGroupPermissionsKey), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), + \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), + \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), + \(openGroup[.userCount]) AS \(ViewModel.Columns.openGroupUserCount), + \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), - \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey), - \(aggregateInteractionLiteral).\(ViewModel.interactionTimestampMsKey), + \(aggregateInteraction[.interactionId]), + \(aggregateInteraction[.interactionTimestampMs]), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(thread[.id]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN ( SELECT - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.threadId]) AS \(ViewModel.threadIdKey), - MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey), - SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey) + \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), + \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), + MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), + SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), + 0 AS \(AggregateInteraction.Columns.threadUnreadMentionCount) FROM \(Interaction.self) WHERE ( \(SQL("\(interaction[.threadId]) = \(threadId)")) AND \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) ) - ) AS \(aggregateInteractionLiteral) ON \(aggregateInteractionLiteral).\(ViewModel.threadIdKey) = \(thread[.id]) + ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN ( SELECT \(groupMember[.groupId]), - COUNT(\(groupMember.alias[Column.rowID])) AS \(ViewModel.closedGroupUserCountKey) + COUNT(\(groupMember[.rowId])) AS \(ClosedGroupUserCount.Columns.closedGroupUserCount) FROM \(GroupMember.self) WHERE ( \(SQL("\(groupMember[.groupId]) = \(threadId)")) AND \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) ) - ) AS \(closedGroupUserCountTableLiteral) ON \(SQL("\(closedGroupUserCountTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(threadId)")) + ) AS \(closedGroupUserCount) ON \(SQL("\(closedGroupUserCount[.groupId]) = \(threadId)")) WHERE \(SQL("\(thread[.id]) = \(threadId)")) """ @@ -980,9 +1020,9 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.disappearingMessagesConfigurationString: adapters[1], - ViewModel.contactProfileString: adapters[2] + return ScopeAdapter.with(ViewModel.self, [ + .disappearingMessagesConfiguration: adapters[1], + .contactProfile: adapters[2] ]) } } @@ -990,39 +1030,40 @@ public extension SessionThreadViewModel { static func conversationSettingsQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 9 let request: SQLRequest = """ SELECT - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), - \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), + \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), + \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), EXISTS ( SELECT 1 @@ -1032,7 +1073,7 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), EXISTS ( SELECT 1 @@ -1042,24 +1083,24 @@ public extension SessionThreadViewModel { \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) ) - ) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), - \(openGroup[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey), - \(openGroup[.publicKey]) AS \(ViewModel.openGroupPublicKeyKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), + \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), + \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1070,9 +1111,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1083,10 +1124,10 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + LEFT JOIN \(closedGroupProfileBackFallback) ON ( \(closedGroup[.threadId]) IS NOT NULL AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userPublicKey)")) ) WHERE \(SQL("\(thread[.id]) = \(threadId)")) @@ -1101,11 +1142,11 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4] ]) } } @@ -1192,13 +1233,15 @@ public extension SessionThreadViewModel { static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest> { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let interactionLiteral: SQL = SQL(stringLiteral: Interaction.databaseTableName) - let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) + let interactionFullTextSearch: TypedTableAlias = TypedTableAlias(name: Interaction.fullTextSearchTableName) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to @@ -1208,44 +1251,44 @@ public extension SessionThreadViewModel { let numColumnsBeforeProfiles: Int = 6 let request: SQLRequest = """ SELECT - \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(interaction[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), - \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), - snippet(\(interactionFullTextSearch), -1, '', '', '...', 6) AS \(ViewModel.interactionBodyKey), + \(interaction[.id]) AS \(ViewModel.Columns.interactionId), + \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), + \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), + snippet(\(interactionFullTextSearch), -1, '', '', '...', 6) AS \(ViewModel.Columns.interactionBody), \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(Interaction.self) JOIN \(interactionFullTextSearch) ON ( - \(interactionFullTextSearch).rowid = \(interactionLiteral).rowid AND - \(interactionFullTextSearch).\(SQL(stringLiteral: Interaction.Columns.body.name)) MATCH \(pattern) + \(interactionFullTextSearch[.rowId]) = \(interaction[.rowId]) AND + \(interactionFullTextSearch[.body]) MATCH \(pattern) ) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(interaction[.threadId]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(interaction[.threadId]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1256,9 +1299,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1269,10 +1312,10 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + LEFT JOIN \(closedGroupProfileBackFallback) ON ( \(closedGroup[.threadId]) IS NOT NULL AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(userPublicKey) ) ORDER BY \(Column.rank), \(interaction[.timestampMs].desc) @@ -1288,11 +1331,11 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4] ]) } } @@ -1315,31 +1358,26 @@ public extension SessionThreadViewModel { /// returned results will always be `-1` for those results static func contactsAndGroupsQuery(userPublicKey: String, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) let groupMember: TypedTableAlias = TypedTableAlias() + let groupMemberProfile: TypedTableAlias = TypedTableAlias(name: "groupMemberProfile") let openGroup: TypedTableAlias = TypedTableAlias() + let groupMemberInfo: TypedTableAlias = TypedTableAlias(name: "groupMemberInfo") let profile: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) - let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) + let profileFullTextSearch: TypedTableAlias = TypedTableAlias(name: Profile.fullTextSearchTableName) + let closedGroupFullTextSearch: TypedTableAlias = TypedTableAlias(name: ClosedGroup.fullTextSearchTableName) + let openGroupFullTextSearch: TypedTableAlias = TypedTableAlias(name: OpenGroup.fullTextSearchTableName) - let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) - let closedGroupNameColumnLiteral: SQL = SQL(stringLiteral: ClosedGroup.Columns.name.name) - let closedGroupLiteral: SQL = SQL(stringLiteral: ClosedGroup.databaseTableName) - let closedGroupFullTextSearch: SQL = SQL(stringLiteral: ClosedGroup.fullTextSearchTableName) - let openGroupNameColumnLiteral: SQL = SQL(stringLiteral: OpenGroup.Columns.name.name) - let openGroupLiteral: SQL = SQL(stringLiteral: OpenGroup.databaseTableName) - let openGroupFullTextSearch: SQL = SQL(stringLiteral: OpenGroup.fullTextSearchTableName) - let groupMemberInfoLiteral: SQL = SQL(stringLiteral: "groupMemberInfo") - let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) - let groupMemberProfileLiteral: SQL = SQL(stringLiteral: "groupMemberProfile") let noteToSelfLiteral: SQL = SQL(stringLiteral: "NOTE_TO_SELF".localized().lowercased()) let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased()) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// We use `IFNULL(rank, 100)` because the custom `Note to Self` like comparison will get a null /// `rank` value which ends up as the first result, by defaulting to `100` it will always be ranked last compared @@ -1350,24 +1388,24 @@ public extension SessionThreadViewModel { SELECT IFNULL(\(Column.rank), 100) AS \(Column.rank), - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), - \(groupMemberInfoLiteral).\(ViewModel.threadMemberNamesKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), + \(groupMemberInfo[.threadMemberNames]), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) @@ -1375,18 +1413,13 @@ public extension SessionThreadViewModel { // MARK: --Contact Threads let contactQueryCommonJoinFilterGroup: SQL = """ - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN \(OpenGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + LEFT JOIN \(closedGroupProfileFront.never) + LEFT JOIN \(closedGroupProfileBack.never) + LEFT JOIN \(closedGroupProfileBackFallback.never) + LEFT JOIN \(closedGroup.never) + LEFT JOIN \(openGroup.never) + LEFT JOIN \(groupMemberInfo.never) WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND @@ -1398,8 +1431,8 @@ public extension SessionThreadViewModel { sqlQuery += selectQuery sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) ) """ sqlQuery += contactQueryCommonJoinFilterGroup @@ -1413,8 +1446,8 @@ public extension SessionThreadViewModel { sqlQuery += selectQuery sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += contactQueryCommonJoinFilterGroup @@ -1429,14 +1462,14 @@ public extension SessionThreadViewModel { LEFT JOIN ( SELECT \(groupMember[.groupId]), - GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(ViewModel.threadMemberNamesKey) + GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(GroupMemberInfo.Columns.threadMemberNames) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) GROUP BY \(groupMember[.groupId]) - ) AS \(groupMemberInfoLiteral) ON \(groupMemberInfoLiteral).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + ) AS \(groupMemberInfo) ON \(groupMemberInfo[.groupId]) = \(closedGroup[.threadId]) + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1447,9 +1480,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1460,13 +1493,13 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(userPublicKey) + LEFT JOIN \(closedGroupProfileBackFallback) ON ( + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(userPublicKey) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false - LEFT JOIN \(OpenGroup.self) ON false + LEFT JOIN \(contactProfile.never) + LEFT JOIN \(openGroup.never) WHERE ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR @@ -1484,8 +1517,8 @@ public extension SessionThreadViewModel { sqlQuery += selectQuery sqlQuery += """ JOIN \(closedGroupFullTextSearch) ON ( - \(closedGroupFullTextSearch).rowid = \(closedGroupLiteral).rowid AND - \(closedGroupFullTextSearch).\(closedGroupNameColumnLiteral) MATCH \(pattern) + \(closedGroupFullTextSearch[.rowId]) = \(closedGroup[.rowId]) AND + \(closedGroupFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += closedGroupQueryCommonJoinFilterGroup @@ -1498,10 +1531,10 @@ public extension SessionThreadViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) + JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND - \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) ) """ sqlQuery += closedGroupQueryCommonJoinFilterGroup @@ -1514,10 +1547,10 @@ public extension SessionThreadViewModel { """ sqlQuery += selectQuery sqlQuery += """ - JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) + JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND - \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += closedGroupQueryCommonJoinFilterGroup @@ -1533,20 +1566,15 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) JOIN \(openGroupFullTextSearch) ON ( - \(openGroupFullTextSearch).rowid = \(openGroupLiteral).rowid AND - \(openGroupFullTextSearch).\(openGroupNameColumnLiteral) MATCH \(pattern) + \(openGroupFullTextSearch[.rowId]) = \(openGroup[.rowId]) AND + \(openGroupFullTextSearch[.name]) MATCH \(pattern) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + LEFT JOIN \(contactProfile.never) + LEFT JOIN \(closedGroupProfileFront.never) + LEFT JOIN \(closedGroupProfileBack.never) + LEFT JOIN \(closedGroupProfileBackFallback.never) + LEFT JOIN \(closedGroup.never) + LEFT JOIN \(groupMemberInfo.never) WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND @@ -1556,18 +1584,13 @@ public extension SessionThreadViewModel { // MARK: --Note to Self Thread let noteToSelfQueryCommonJoins: SQL = """ - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(OpenGroup.self) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + LEFT JOIN \(closedGroupProfileFront.never) + LEFT JOIN \(closedGroupProfileBack.never) + LEFT JOIN \(closedGroupProfileBackFallback.never) + LEFT JOIN \(openGroup.never) + LEFT JOIN \(closedGroup.never) + LEFT JOIN \(groupMemberInfo.never) """ // Note to self thread searching for 'Note to Self' (need to join an FTS table to @@ -1600,8 +1623,8 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) ) """ sqlQuery += noteToSelfQueryCommonJoins @@ -1620,8 +1643,8 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += noteToSelfQueryCommonJoins @@ -1635,41 +1658,36 @@ public extension SessionThreadViewModel { SELECT IFNULL(\(Column.rank), 100) AS \(Column.rank), - -1 AS \(ViewModel.rowIdKey), - \(contact[.id]) AS \(ViewModel.threadIdKey), - \(SQL("\(SessionThread.Variant.contact)")) AS \(ViewModel.threadVariantKey), - 0 AS \(ViewModel.threadCreationDateTimestampKey), - \(groupMemberInfoLiteral).\(ViewModel.threadMemberNamesKey), + -1 AS \(ViewModel.Columns.rowId), + \(contact[.id]) AS \(ViewModel.Columns.threadId), + \(SQL("\(SessionThread.Variant.contact)")) AS \(ViewModel.Columns.threadVariant), + 0 AS \(ViewModel.Columns.threadCreationDateTimestamp), + \(groupMemberInfo[.threadMemberNames]), - false AS \(ViewModel.threadIsNoteToSelfKey), - -1 AS \(ViewModel.threadPinnedPriorityKey), + false AS \(ViewModel.Columns.threadIsNoteToSelf), + -1 AS \(ViewModel.Columns.threadPinnedPriority), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(Contact.self) """ let hiddenContactQueryCommonJoins: SQL = """ - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(contact[.id]) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(contact[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON false - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON false - LEFT JOIN \(ClosedGroup.self) ON false - LEFT JOIN \(OpenGroup.self) ON false - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - '' AS \(ViewModel.threadMemberNamesKey) - FROM \(GroupMember.self) - ) AS \(groupMemberInfoLiteral) ON false + LEFT JOIN \(closedGroupProfileFront.never) + LEFT JOIN \(closedGroupProfileBack.never) + LEFT JOIN \(closedGroupProfileBackFallback.never) + LEFT JOIN \(closedGroup.never) + LEFT JOIN \(openGroup.never) + LEFT JOIN \(groupMemberInfo.never) WHERE \(thread[.id]) IS NULL GROUP BY \(contact[.id]) @@ -1685,8 +1703,8 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) ) """ sqlQuery += hiddenContactQueryCommonJoins @@ -1701,8 +1719,8 @@ public extension SessionThreadViewModel { sqlQuery += """ JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND - \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) ) """ sqlQuery += hiddenContactQueryCommonJoins @@ -1717,13 +1735,13 @@ public extension SessionThreadViewModel { \(sqlQuery) ) - GROUP BY \(ViewModel.threadIdKey) + GROUP BY \(ViewModel.Columns.threadId) ORDER BY \(Column.rank), - \(ViewModel.threadIsNoteToSelfKey), - \(ViewModel.closedGroupNameKey), - \(ViewModel.openGroupNameKey), - \(ViewModel.threadIdKey) + \(ViewModel.Columns.threadIsNoteToSelf), + \(ViewModel.Columns.closedGroupName), + \(ViewModel.Columns.openGroupName), + \(ViewModel.Columns.threadId) LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) """ @@ -1752,11 +1770,11 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4] ]) } } @@ -1764,31 +1782,30 @@ public extension SessionThreadViewModel { /// This method returns only the 'Note to Self' thread in the structure of a search result conversation static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw let numColumnsBeforeProfiles: Int = 8 let request: SQLRequest = """ SELECT 100 AS \(Column.rank), - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), - '' AS \(ViewModel.threadMemberNamesKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), + '' AS \(ViewModel.Columns.threadMemberNames), - true AS \(ViewModel.threadIsNoteToSelfKey), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), + true AS \(ViewModel.Columns.threadIsNoteToSelf), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(ViewModel.contactProfileKey).*, + \(contactProfile.allColumns), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) - JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) """ @@ -1801,8 +1818,8 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1] ]) } } @@ -1814,67 +1831,70 @@ public extension SessionThreadViewModel { static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() + let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) + let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) + let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() + let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") let interaction: TypedTableAlias = TypedTableAlias() - let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw + /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 7 let request: SQLRequest = """ SELECT - \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), - \(thread[.id]) AS \(ViewModel.threadIdKey), - \(thread[.variant]) AS \(ViewModel.threadVariantKey), - \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), + \(thread[.rowId]) AS \(ViewModel.Columns.rowId), + \(thread[.id]) AS \(ViewModel.Columns.threadId), + \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), + \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), - \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), + \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - \(ViewModel.contactProfileKey).*, - \(ViewModel.closedGroupProfileFrontKey).*, - \(ViewModel.closedGroupProfileBackKey).*, - \(ViewModel.closedGroupProfileBackFallbackKey).*, - \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), - \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), + \(contactProfile.allColumns), + \(closedGroupProfileFront.allColumns), + \(closedGroupProfileBack.allColumns), + \(closedGroupProfileBackFallback.allColumns), + \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), - \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) + \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN ( SELECT - \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.threadId]) AS \(ViewModel.threadIdKey), - MAX(\(interaction[.timestampMs])) + \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), + \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), + MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), + 0 AS \(AggregateInteraction.Columns.threadUnreadCount), + 0 AS \(AggregateInteraction.Columns.threadUnreadMentionCount) FROM \(Interaction.self) WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) - ) AS \(aggregateInteractionLiteral) ON \(aggregateInteractionLiteral).\(ViewModel.threadIdKey) = \(thread[.id]) + ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) LEFT JOIN \(Interaction.self) ON ( \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.id]) = \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey) + \(interaction[.id]) = \(aggregateInteraction[.interactionId]) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( - \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileFront) ON ( + \(closedGroupProfileFront[.id]) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1885,9 +1905,9 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON ( - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = ( + LEFT JOIN \(closedGroupProfileBack) ON ( + \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND + \(closedGroupProfileBack[.id]) = ( SELECT MAX(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) @@ -1898,10 +1918,10 @@ public extension SessionThreadViewModel { ) ) ) - LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON ( + LEFT JOIN \(closedGroupProfileBackFallback) ON ( \(closedGroup[.threadId]) IS NOT NULL AND - \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND - \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) + \(closedGroupProfileBack[.id]) IS NULL AND + \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userPublicKey)")) ) WHERE ( @@ -1929,11 +1949,11 @@ public extension SessionThreadViewModel { Profile.numberOfSelectedColumns(db) ]) - return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1], - ViewModel.closedGroupProfileFrontString: adapters[2], - ViewModel.closedGroupProfileBackString: adapters[3], - ViewModel.closedGroupProfileBackFallbackString: adapters[4] + return ScopeAdapter.with(ViewModel.self, [ + .contactProfile: adapters[1], + .closedGroupProfileFront: adapters[2], + .closedGroupProfileBack: adapters[3], + .closedGroupProfileBackFallback: adapters[4] ]) } } diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index ab6ae915f..2c41643c2 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -1351,18 +1351,24 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet // Fetch the inserted/updated rows let additionalFilters: SQL = SQL(rowIds.contains(Column.rowID)) - let updatedItems: [T] = (try? dataQuery(additionalFilters) - .fetchAll(db)) - .defaulting(to: []) - // If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link - // preview) then trigger the update callback (if there were deletions) and stop here - guard !updatedItems.isEmpty else { return hasOtherChanges } - - // Process the upserted data (assume at least one value changed) - dataCache.mutate { $0 = $0.upserting(items: updatedItems) } - - return true + do { + let updatedItems: [T] = try dataQuery(additionalFilters) + .fetchAll(db) + + // If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link + // preview) then trigger the update callback (if there were deletions) and stop here + guard !updatedItems.isEmpty else { return hasOtherChanges } + + // Process the upserted data (assume at least one value changed) + dataCache.mutate { $0 = $0.upserting(items: updatedItems) } + + return true + } + catch { + SNLog("[PagedDatabaseObserver] Error loading associated data: \(error)") + return hasOtherChanges + } } public func clearCache(_ db: Database) { diff --git a/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift index fa91388c4..ed42d63e0 100644 --- a/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift +++ b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift @@ -3,28 +3,65 @@ import Foundation import GRDB -public class TypedTableAlias where T: TableRecord, T: ColumnExpressible { - internal let name: String - internal let tableName: String - public let alias: TableAlias +public struct TypedTableAlias { + public enum RowIdColumn { + case rowId + } - public init(name: String = T.databaseTableName) { + internal let name: String + internal let tableName: String? + internal let alias: TableAlias + + public var allColumns: SQLSelection { alias[AllColumns().sqlSelection] } + public var never: NeverJoiningTypedTableAlias { NeverJoiningTypedTableAlias(alias: self) } + + // MARK: - Initialization + + public init(name: String, tableName: String? = nil) { + self.name = name + self.tableName = tableName + self.alias = TableAlias(name: name) + } + + public init(name: String) where T: TableRecord { self.name = name self.tableName = T.databaseTableName self.alias = TableAlias(name: name) } + public init() where T: TableRecord { + self = TypedTableAlias(name: T.databaseTableName) + } + + public init(_ viewModel: VM.Type, column: VM.Columns, tableName: String?) { + self.name = column.name + self.tableName = tableName + self.alias = TableAlias(name: name) + } + + public init(_ viewModel: VM.Type, column: VM.Columns) where T: TableRecord { + self = TypedTableAlias(viewModel, column: column, tableName: T.databaseTableName) + } + + // MARK: - Functions + public subscript(_ column: T.Columns) -> SQLExpression { return alias[column.name] } - /// **Warning:** For this to work you **MUST** call the '.aliased()' method when joining or it will - /// throw when trying to decode - public func allColumns() -> SQLSelection { - return alias[AllColumns().sqlSelection] + public subscript(_ column: RowIdColumn) -> SQLSelection { + return alias[Column.rowID] } } +// MARK: - NeverJoiningTypedTableAlias + +public struct NeverJoiningTypedTableAlias { + internal let alias: TypedTableAlias +} + +// MARK: - Extensions + extension QueryInterfaceRequest { public func aliased(_ typedAlias: TypedTableAlias) -> Self { return aliased(typedAlias.alias) @@ -38,7 +75,5 @@ extension Association { } extension TableAlias { - public func allColumns() -> SQLSelection { - return self[AllColumns().sqlSelection] - } + public var allColumns: SQLSelection { self[AllColumns().sqlSelection] } } diff --git a/SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift index d8918ef17..01d0c64bb 100644 --- a/SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/SQLInterpolation+Utilities.swift @@ -12,10 +12,58 @@ public extension SQLInterpolation { @_disfavoredOverload mutating func appendInterpolation(_ typedTableAlias: TypedTableAlias) { let name: String = typedTableAlias.name - let tableName: String = typedTableAlias.tableName + guard let tableName: String = typedTableAlias.tableName else { return appendLiteral(name.quotedDatabaseIdentifier) } guard name != tableName else { return appendLiteral(tableName.quotedDatabaseIdentifier) } appendLiteral("\(tableName.quotedDatabaseIdentifier) AS \(name.quotedDatabaseIdentifier)") } + + /// Appends a simple SQL query for use when we want a `LEFT JOIN` that will always fail + /// + /// // SELECT * FROM player LEFT JOIN team AS testTeam ON false + /// let player: TypedTableAlias = TypedTableAlias() + /// let testTeam: TypedTableAlias = TypedTableAlias(name: "testTeam") + /// let request: SQLRequest = "SELECT * FROM \(player) LEFT JOIN \(testTeam.never) + @_disfavoredOverload + mutating func appendInterpolation(_ neverJoiningAlias: NeverJoiningTypedTableAlias) where T: TableRecord { + guard let tableName: String = neverJoiningAlias.alias.tableName else { + appendLiteral("(SELECT \(generateSelection(for: T.self))) AS \(neverJoiningAlias.alias.name.quotedDatabaseIdentifier) ON false") + return + } + + appendLiteral("\(tableName.quotedDatabaseIdentifier) AS \(neverJoiningAlias.alias.name.quotedDatabaseIdentifier) ON false") + } + + /// Appends a simple SQL query for use when we want a `LEFT JOIN` that will always fail + /// + /// // SELECT * FROM player LEFT JOIN (SELECT 0 AS teamInfo.Column.A, 0 AS teamInfo.Column.B) AS teamInfo ON false + /// let player: TypedTableAlias = TypedTableAlias() + /// let teamInfo: TypedTableAlias = TypedTableAlias(name: "teamInfo") + /// let request: SQLRequest = "SELECT * FROM \(player) LEFT JOIN \(teamInfo.never) + @_disfavoredOverload + mutating func appendInterpolation(_ neverJoiningAlias: NeverJoiningTypedTableAlias) where T.Columns: CaseIterable { + appendLiteral("(SELECT \(generateSelection(for: T.self))) AS \(neverJoiningAlias.alias.name.quotedDatabaseIdentifier) ON false") + } + + /// Appends a simple SQL query for use when we want a `LEFT JOIN` that will always fail + /// + /// // SELECT * FROM player LEFT JOIN (SELECT 0 AS teamInfo.Column.A, 0 AS teamInfo.Column.B) AS teamInfo ON false + /// let player: TypedTableAlias = TypedTableAlias() + /// let teamInfo: TypedTableAlias = TypedTableAlias(name: "teamInfo") + /// let request: SQLRequest = "SELECT * FROM \(player) LEFT JOIN \(teamInfo.never) + @_disfavoredOverload + mutating func appendInterpolation(_ neverJoiningAlias: NeverJoiningTypedTableAlias) { + appendLiteral("(SELECT \(generateSelection(for: T.self))) AS \(neverJoiningAlias.alias.name.quotedDatabaseIdentifier) ON false") + } + + private func generateSelection(for type: T.Type) -> String where T.Columns: CaseIterable { + return T.Columns.allCases + .map { "NULL AS \($0.name)" } + .joined(separator: ", ") + } + + private func generateSelection(for type: T.Type) -> String { + return "SELECT 1" + } } diff --git a/SessionUtilitiesKit/Database/Utilities/ScopeAdapter+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/ScopeAdapter+Utilities.swift new file mode 100644 index 000000000..500131972 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/ScopeAdapter+Utilities.swift @@ -0,0 +1,13 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension ScopeAdapter { + static func with( + _ viewModel: VM.Type, + _ scopes: [VM.Columns: RowAdapter] + ) -> ScopeAdapter { + return ScopeAdapter(scopes.reduce(into: [:]) { result, next in result[next.key.name] = next.value }) + } +} From 8e28726fa78800778581aaae1ff56c76d807136d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 17 Aug 2023 18:06:06 +1000 Subject: [PATCH 45/50] Fixed a bug where unwritable conversations appeared in the share extension --- .../SessionThreadViewModel.swift | 22 ++++++++++++++++++- .../ThreadPickerViewModel.swift | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 87a09f337..841f844e9 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -1846,7 +1846,7 @@ public extension SessionThreadViewModel { /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 7 + let numColumnsBeforeProfiles: Int = 8 let request: SQLRequest = """ SELECT @@ -1856,6 +1856,11 @@ public extension SessionThreadViewModel { \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND + IFNULL(\(contact[.isApproved]), false) = false + ) AS \(ViewModel.Columns.threadIsMessageRequest), IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), @@ -1865,8 +1870,23 @@ public extension SessionThreadViewModel { \(closedGroupProfileBack.allColumns), \(closedGroupProfileBackFallback.allColumns), \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), + \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), \(openGroup[.imageData]) AS \(ViewModel.Columns.openGroupProfilePictureData), + \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), + + \(interaction[.id]) AS \(ViewModel.Columns.interactionId), + \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), \(SQL("\(userPublicKey)")) AS \(ViewModel.Columns.currentUserPublicKey) diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 2d07a43cd..e2bae7488 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -28,6 +28,7 @@ public class ThreadPickerViewModel { .shareQuery(userPublicKey: userPublicKey) .fetchAll(db) } + .map { threads -> [SessionThreadViewModel] in threads.filter { $0.canWrite } } // Exclude unwritable threads .removeDuplicates() .handleEvents(didFail: { SNLog("[ThreadPickerViewModel] Observation failed with error: \($0)") }) From 252e85fef9e3b68cfde8b18be2e88e178b721ec2 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 18 Aug 2023 16:14:25 +1000 Subject: [PATCH 46/50] Fixed a bug where the swipe-to-reply wasn't moving the message status text --- Session/Conversations/Message Cells/VisibleMessageCell.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 3a016fa51..c264472ea 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -52,7 +52,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { profilePictureView, replyButton, timerView, - messageStatusImageView, + messageStatusContainerView, reactionContainerView ] From dfdf843f66fe40f900fed8957fc8298ec92eb481 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 21 Aug 2023 16:13:17 +1000 Subject: [PATCH 47/50] Fixed a few more issues Fixed an issue where tapping a remote notification wasn't opening the conversation Fixed an issue where the Share Extension could fail due to not having a snode pool loaded Fixed an issue where the Theme may not be applied at the right time in the Share extension --- .../ConversationVC+Interaction.swift | 21 ++- .../SendMediaNavigationController.swift | 35 ++-- .../Open Groups/OpenGroupManager.swift | 4 +- .../NSENotificationPresenter.swift | 21 ++- .../NotificationServiceExtension.swift | 1 + .../ShareNavController.swift | 9 +- SessionShareExtension/ThreadPickerVC.swift | 167 +++++++++++------- SessionSnodeKit/Jobs/GetSnodePoolJob.swift | 2 +- SessionSnodeKit/Networking/SnodeAPI.swift | 13 +- .../AttachmentApprovalViewController.swift | 21 ++- 10 files changed, 197 insertions(+), 97 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 2b0def3f6..9d7c469a1 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -154,6 +154,7 @@ extension ConversationVC: _ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, + threadVariant: SessionThread.Variant, messageText: String?, using dependencies: Dependencies ) { @@ -180,7 +181,14 @@ extension ConversationVC: // MARK: - AttachmentApprovalViewControllerDelegate - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?, using dependencies: Dependencies) { + func attachmentApproval( + _ attachmentApproval: AttachmentApprovalViewController, + didApproveAttachments attachments: [SignalAttachment], + forThreadId threadId: String, + threadVariant: SessionThread.Variant, + messageText: String?, + using dependencies: Dependencies + ) { sendMessage(text: (messageText ?? ""), attachments: attachments, using: dependencies) resetMentions() @@ -255,11 +263,13 @@ extension ConversationVC: func handleLibraryButtonTapped() { let threadId: String = self.viewModel.threadData.threadId + let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant Permissions.requestLibraryPermissionIfNeeded { [weak self] in DispatchQueue.main.async { let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst( - threadId: threadId + threadId: threadId, + threadVariant: threadVariant ) sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen @@ -277,7 +287,10 @@ extension ConversationVC: SNLog("Proceeding without microphone access. Any recorded video will be silent.") } - let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(threadId: self.viewModel.threadData.threadId) + let sendMediaNavController = SendMediaNavigationController.showingCameraFirst( + threadId: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant + ) sendMediaNavController.sendMediaNavDelegate = self sendMediaNavController.modalPresentationStyle = .fullScreen @@ -363,6 +376,7 @@ extension ConversationVC: func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) { let navController = AttachmentApprovalViewController.wrappedInNavController( threadId: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, attachments: attachments, approvalDelegate: self ) @@ -647,6 +661,7 @@ extension ConversationVC: let approvalVC = AttachmentApprovalViewController.wrappedInNavController( threadId: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, attachments: [ attachment ], approvalDelegate: self ) diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 32cf44c16..e706d08be 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -18,12 +18,14 @@ class SendMediaNavigationController: UINavigationController { static let bottomButtonsCenterOffset: CGFloat = -50 private let threadId: String + private let threadVariant: SessionThread.Variant private var disposables: Set = Set() // MARK: - Initialization - init(threadId: String) { + init(threadId: String, threadVariant: SessionThread.Variant) { self.threadId = threadId + self.threadVariant = threadVariant super.init(nibName: nil, bundle: nil) } @@ -74,17 +76,15 @@ class SendMediaNavigationController: UINavigationController { public weak var sendMediaNavDelegate: SendMediaNavDelegate? - @objc - public class func showingCameraFirst(threadId: String) -> SendMediaNavigationController { - let navController = SendMediaNavigationController(threadId: threadId) + public class func showingCameraFirst(threadId: String, threadVariant: SessionThread.Variant) -> SendMediaNavigationController { + let navController = SendMediaNavigationController(threadId: threadId, threadVariant: threadVariant) navController.viewControllers = [navController.captureViewController] return navController } - @objc - public class func showingMediaLibraryFirst(threadId: String) -> SendMediaNavigationController { - let navController = SendMediaNavigationController(threadId: threadId) + public class func showingMediaLibraryFirst(threadId: String, threadVariant: SessionThread.Variant) -> SendMediaNavigationController { + let navController = SendMediaNavigationController(threadId: threadId, threadVariant: threadVariant) navController.viewControllers = [navController.mediaLibraryViewController] return navController @@ -233,6 +233,7 @@ class SendMediaNavigationController: UINavigationController { let approvalViewController = AttachmentApprovalViewController( mode: .sharedNavigation, threadId: self.threadId, + threadVariant: self.threadVariant, attachments: self.attachments ) approvalViewController.approvalDelegate = self @@ -431,8 +432,22 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat attachmentDraftCollection.remove(attachment: attachment) } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?, using dependencies: Dependencies) { - sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: messageText, using: dependencies) + func attachmentApproval( + _ attachmentApproval: AttachmentApprovalViewController, + didApproveAttachments attachments: [SignalAttachment], + forThreadId threadId: String, + threadVariant: SessionThread.Variant, + messageText: String?, + using dependencies: Dependencies + ) { + sendMediaNavDelegate?.sendMediaNav( + self, + didApproveAttachments: attachments, + forThreadId: threadId, + threadVariant: threadVariant, + messageText: messageText, + using: dependencies + ) } func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { @@ -765,7 +780,7 @@ private class DoneButton: UIView { protocol SendMediaNavDelegate: AnyObject { func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?) - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?, using dependencies: Dependencies) + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String?, using dependencies: Dependencies) func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 1af88d959..b45dfcf2c 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -786,14 +786,14 @@ public final class OpenGroupManager { } } - if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo { + if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo, let proto: SNProtoContent = processedMessage?.proto { try MessageReceiver.handle( db, threadId: (lookup.sessionId ?? lookup.blindedId), threadVariant: .contact, // Technically not open group messages message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), + associatedWithProto: proto, using: dependencies ) } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index f091f4eb6..2a0d12b80 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -45,8 +45,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { .replacingMentions(for: thread.id)) .defaulting(to: "APN_Message".localized()) - var userInfo: [String: Any] = [ NotificationServiceExtension.isFromRemoteKey: true ] - userInfo[NotificationServiceExtension.threadIdKey] = thread.id + let userInfo: [String: Any] = [ + NotificationServiceExtension.isFromRemoteKey: true, + NotificationServiceExtension.threadIdKey: thread.id, + NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue + ] let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo @@ -145,8 +148,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // Only notify missed calls guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return } - var userInfo: [String: Any] = [ NotificationServiceExtension.isFromRemoteKey: true ] - userInfo[NotificationServiceExtension.threadIdKey] = thread.id + let userInfo: [String: Any] = [ + NotificationServiceExtension.isFromRemoteKey: true, + NotificationServiceExtension.threadIdKey: thread.id, + NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue + ] let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo @@ -206,8 +212,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { default: notificationBody = NotificationStrings.incomingMessageBody } - var userInfo: [String: Any] = [ NotificationServiceExtension.isFromRemoteKey: true ] - userInfo[NotificationServiceExtension.threadIdKey] = thread.id + let userInfo: [String: Any] = [ + NotificationServiceExtension.isFromRemoteKey: true, + NotificationServiceExtension.threadIdKey: thread.id, + NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue + ] let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 22c6f4948..a80b424f5 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -18,6 +18,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension public static let isFromRemoteKey = "remote" public static let threadIdKey = "Signal.AppNotificationsUserInfoKey.threadId" + public static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" public static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" // MARK: Did receive a remote push notification request diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 22c0ce612..1789ab470 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -36,10 +36,6 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { SetCurrentAppContext(appContext) } - // Need to manually trigger these since we don't have a "mainWindow" here and the current theme - // might have been changed since the share extension was last opened - ThemeManager.applySavedTheme() - Logger.info("") _ = AppVersion.sharedInstance() @@ -66,6 +62,11 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { case .failure: SNLog("[SessionShareExtension] Failed to complete migrations") case .success: DispatchQueue.main.async { + // Need to manually trigger these since we don't have a "mainWindow" here + // and the current theme might have been changed since the share extension + // was last opened + ThemeManager.applySavedTheme() + // performUpdateCheck must be invoked after Environment has been initialized because // upgrade process may depend on Environment. self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index afc92180a..ced7e707c 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -185,6 +185,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController( threadId: strongSelf.viewModel.viewData[indexPath.row].threadId, + threadVariant: strongSelf.viewModel.viewData[indexPath.row].threadVariant, attachments: attachments, approvalDelegate: strongSelf ) @@ -197,6 +198,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView _ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, + threadVariant: SessionThread.Variant, messageText: String?, using dependencies: Dependencies = Dependencies() ) { @@ -221,78 +223,111 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in Storage.resumeDatabaseAccess() - dependencies.storage - .writePublisher { db -> MessageSender.PreparedSendData in - guard - let threadVariant: SessionThread.Variant = try SessionThread - .filter(id: threadId) - .select(.variant) - .asRequest(of: SessionThread.Variant.self) - .fetchOne(db) - else { throw MessageSenderError.noThread } - - // Create the interaction - let interaction: Interaction = try Interaction( - threadId: threadId, - authorId: getUserHexEncodedPublicKey(db), - variant: .standardOutgoing, - body: body, - timestampMs: SnodeAPI.currentOffsetTimestampMs(), - hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: body), - expiresInSeconds: try? DisappearingMessagesConfiguration - .select(.durationSeconds) - .filter(id: threadId) - .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) - .asRequest(of: TimeInterval.self) - .fetchOne(db), - linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil) - ).inserted(db) - - guard let interactionId: Int64 = interaction.id else { - throw StorageError.failedToSave + /// When we prepare the message we set the timestamp to be the `SnodeAPI.currentOffsetTimestampMs()` + /// but won't actually have a value because the share extension won't have talked to a service node yet which can cause + /// issues with Disappearing Messages, as a result we need to explicitly `getNetworkTime` in order to ensure it's accurate + Just(()) + .setFailureType(to: Error.self) + .flatMap { _ in + // We may not have sufficient snodes, so rather than failing we try to load/fetch + // them if needed + guard !SnodeAPI.hasCachedSnodesIncludingExpired() else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - // If the user is sharing a Url, there is a LinkPreview and it doesn't match an existing - // one then add it now - if - isSharingUrl, - let linkPreviewDraft: LinkPreviewDraft = attachments.first?.linkPreviewDraft, - (try? interaction.linkPreview.isEmpty(db)) == true - { - try LinkPreview( - url: linkPreviewDraft.urlString, - title: linkPreviewDraft.title, - attachmentId: LinkPreview - .generateAttachmentIfPossible( - imageData: linkPreviewDraft.jpegImageData, - mimeType: OWSMimeTypeImageJpeg - )? - .inserted(db) - .id - ).insert(db) - } - - // Prepare any attachments - try Attachment.process( - db, - data: Attachment.prepare(attachments: finalAttachments), - for: interactionId - ) - - // Prepare the message send data - return try MessageSender - .preparedSendData( - db, - interaction: interaction, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) + return SnodeAPI.getSnodePool() + .map { _ in () } + .eraseToAnyPublisher() } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .flatMap { _ in + SnodeAPI + .getSwarm( + for: { + switch threadVariant { + case .contact, .legacyGroup, .group: return threadId + case .community: return getUserHexEncodedPublicKey(using: dependencies) + } + }(), + using: dependencies + ) + .tryFlatMapWithRandomSnode { SnodeAPI.getNetworkTime(from: $0, using: dependencies) } + .map { _ in () } + .eraseToAnyPublisher() + } + .flatMap { _ in + dependencies.storage.writePublisher { db -> MessageSender.PreparedSendData in + guard + let threadVariant: SessionThread.Variant = try SessionThread + .filter(id: threadId) + .select(.variant) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db) + else { throw MessageSenderError.noThread } + + // Create the interaction + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: body, + timestampMs: SnodeAPI.currentOffsetTimestampMs(), + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: body), + expiresInSeconds: try? DisappearingMessagesConfiguration + .select(.durationSeconds) + .filter(id: threadId) + .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) + .asRequest(of: TimeInterval.self) + .fetchOne(db), + linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil) + ).inserted(db) + + guard let interactionId: Int64 = interaction.id else { + throw StorageError.failedToSave + } + + // If the user is sharing a Url, there is a LinkPreview and it doesn't match an existing + // one then add it now + if + isSharingUrl, + let linkPreviewDraft: LinkPreviewDraft = attachments.first?.linkPreviewDraft, + (try? interaction.linkPreview.isEmpty(db)) == true + { + try LinkPreview( + url: linkPreviewDraft.urlString, + title: linkPreviewDraft.title, + attachmentId: LinkPreview + .generateAttachmentIfPossible( + imageData: linkPreviewDraft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + )? + .inserted(db) + .id + ).insert(db) + } + + // Prepare any attachments + try Attachment.process( + db, + data: Attachment.prepare(attachments: finalAttachments), + for: interactionId + ) + + // Prepare the message send data + return try MessageSender + .preparedSendData( + db, + interaction: interaction, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) + } + } .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0, using: dependencies) } .flatMap { MessageSender.sendImmediate(data: $0, using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { [weak self] result in diff --git a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift index 22cdad93d..72844ff82 100644 --- a/SessionSnodeKit/Jobs/GetSnodePoolJob.swift +++ b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift @@ -23,7 +23,7 @@ public enum GetSnodePoolJob: JobExecutor { // but we want to succeed this job immediately (since it's marked as blocking), this allows us // to block if we have no Snode pool and prevent other jobs from failing but avoids having to // wait if we already have a potentially valid snode pool - guard !SnodeAPI.hasCachedSnodesInclusingExpired() else { + guard !SnodeAPI.hasCachedSnodesIncludingExpired() else { SNLog("[GetSnodePoolJob] Has valid cached pool, running async instead") SnodeAPI .getSnodePool() diff --git a/SessionSnodeKit/Networking/SnodeAPI.swift b/SessionSnodeKit/Networking/SnodeAPI.swift index 159d13fad..a2e9af6a8 100644 --- a/SessionSnodeKit/Networking/SnodeAPI.swift +++ b/SessionSnodeKit/Networking/SnodeAPI.swift @@ -141,7 +141,7 @@ public final class SnodeAPI { // MARK: - Public API - public static func hasCachedSnodesInclusingExpired() -> Bool { + public static func hasCachedSnodesIncludingExpired() -> Bool { loadSnodePoolIfNeeded() return !hasInsufficientSnodes @@ -1009,7 +1009,7 @@ public final class SnodeAPI { // MARK: - Internal API - private static func getNetworkTime( + public static func getNetworkTime( from snode: Snode, using dependencies: Dependencies = Dependencies() ) -> AnyPublisher { @@ -1024,7 +1024,14 @@ public final class SnodeAPI { using: dependencies ) .decoded(as: GetNetworkTimestampResponse.self, using: dependencies) - .map { _, response in response.timestamp } + .map { _, response in + // Assume we've fetched the networkTime in order to send a message to the specified snode, in + // which case we want to update the 'clockOffsetMs' value for subsequent requests + let offset = (Int64(response.timestamp) - Int64(floor(dependencies.dateNow.timeIntervalSince1970 * 1000))) + SnodeAPI.clockOffsetMs.mutate { $0 = offset } + + return response.timestamp + } .eraseToAnyPublisher() } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index a6b1a8782..77b13ab15 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -16,6 +16,7 @@ public protocol AttachmentApprovalViewControllerDelegate: AnyObject { _ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, + threadVariant: SessionThread.Variant, messageText: String?, using dependencies: Dependencies ) @@ -59,6 +60,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC private let mode: Mode private let threadId: String + private let threadVariant: SessionThread.Variant private let isAddMoreVisible: Bool public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate? @@ -128,11 +130,13 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC required public init( mode: Mode, threadId: String, + threadVariant: SessionThread.Variant, attachments: [SignalAttachment] ) { assert(attachments.count > 0) self.mode = mode self.threadId = threadId + self.threadVariant = threadVariant let attachmentItems = attachments.map { SignalAttachmentItem(attachment: $0 )} self.isAddMoreVisible = (mode == .sharedNavigation) @@ -162,10 +166,16 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC public class func wrappedInNavController( threadId: String, + threadVariant: SessionThread.Variant, attachments: [SignalAttachment], approvalDelegate: AttachmentApprovalViewControllerDelegate ) -> UINavigationController { - let vc = AttachmentApprovalViewController(mode: .modal, threadId: threadId, attachments: attachments) + let vc = AttachmentApprovalViewController( + mode: .modal, + threadId: threadId, + threadVariant: threadVariant, + attachments: attachments + ) vc.approvalDelegate = approvalDelegate let navController = StyledNavigationController(rootViewController: vc) @@ -674,7 +684,14 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { attachmentTextToolbar.isUserInteractionEnabled = false attachmentTextToolbar.isHidden = true - approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: attachmentTextToolbar.messageText, using: dependencies) + approvalDelegate?.attachmentApproval( + self, + didApproveAttachments: attachments, + forThreadId: threadId, + threadVariant: threadVariant, + messageText: attachmentTextToolbar.messageText, + using: dependencies + ) } func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar) { From f3b2cc577c353bcaf6f4e6a03c384629feff1b73 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 23 Aug 2023 10:30:21 +1000 Subject: [PATCH 48/50] Fixed the settings bundle and added Cocoapod acknowledgements --- ...pendencies-Session-settings-metadata.plist | 1889 +++++++++++++++++ Session/Meta/Settings.bundle/Root.plist | 50 +- .../Settings.bundle/en.lproj/Root.strings | Bin 546 -> 0 bytes 3 files changed, 1893 insertions(+), 46 deletions(-) create mode 100644 Session/Meta/Settings.bundle/Pods-GlobalDependencies-Session-settings-metadata.plist delete mode 100644 Session/Meta/Settings.bundle/en.lproj/Root.strings diff --git a/Session/Meta/Settings.bundle/Pods-GlobalDependencies-Session-settings-metadata.plist b/Session/Meta/Settings.bundle/Pods-GlobalDependencies-Session-settings-metadata.plist new file mode 100644 index 000000000..bcd827b41 --- /dev/null +++ b/Session/Meta/Settings.bundle/Pods-GlobalDependencies-Session-settings-metadata.plist @@ -0,0 +1,1889 @@ + + + + + PreferenceSpecifiers + + + FooterText + This application makes use of the following third party libraries: + Title + Acknowledgements + Type + PSGroupSpecifier + + + FooterText + + Title + xcbeautify + Type + PSGroupSpecifier + + + FooterText + BSD 3-Clause License + +Copyright (c) 2010-2022, Deusty, LLC +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of Deusty nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission of Deusty, LLC. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Title + CocoaLumberjack + Type + PSGroupSpecifier + + + FooterText + Copyright (C) 2015-2023 Gwendal Roué + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + Title + GRDB.swift + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2008, ZETETIC LLC +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the ZETETIC LLC nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY ZETETIC LLC ''AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Title + SQLCipher + Type + PSGroupSpecifier + + + FooterText + ISC License + +Copyright (c) 2014-2020, Frank Denis <j at pureftpd dot org> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + + Title + Sodium + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2011, The WebRTC project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Title + WebRTC-lib + Type + PSGroupSpecifier + + + FooterText + Software License Agreement (BSD License) + +Copyright (c) 2013, yap.TV Inc. +All rights reserved. + +Redistribution and use of this software in source and binary forms, +with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +* Neither the name of yap.TV nor the names of its + contributors may be used to endorse or promote products + derived from this software without specific prior + written permission of yap.TV Inc. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + Title + YapDatabase + Type + PSGroupSpecifier + + + FooterText + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + Title + DifferenceKit + Type + PSGroupSpecifier + + + FooterText + The MIT License (MIT) + +Copyright (c) 2016 Vinh Nguyen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + Title + NVActivityIndicatorView + Type + PSGroupSpecifier + + + FooterText + This code is distributed under the terms and conditions of the MIT license. + +Copyright (c) 2014-2015 Tyler Fox + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + Title + PureLayout + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2011, Tony Million. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + Title + Reachability + Type + PSGroupSpecifier + + + FooterText + The MIT License (MIT) + +Copyright (c) 2015 ibireme <ibireme@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + Title + YYImage + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + Title + libwebp + Type + PSGroupSpecifier + + + FooterText + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/> + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {description} + Copyright (C) {year} {fullname} + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + {signature of Ty Coon}, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. + Title + Curve25519Kit + Type + PSGroupSpecifier + + + FooterText + + LICENSE ISSUES + ============== + + The OpenSSL toolkit stays under a dual license, i.e. both the conditions of + the OpenSSL License and the original SSLeay license apply to the toolkit. + See below for the actual license texts. Actually both licenses are BSD-style + Open Source licenses. In case of any license issues related to OpenSSL + please contact openssl-core@openssl.org. + + OpenSSL License + --------------- + +/* ==================================================================== + * Copyright (c) 1998-2008 The OpenSSL Project. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. All advertising materials mentioning features or use of this + * software must display the following acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + * + * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For written permission, please contact + * openssl-core@openssl.org. + * + * 5. Products derived from this software may not be called "OpenSSL" + * nor may "OpenSSL" appear in their names without prior written + * permission of the OpenSSL Project. + * + * 6. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit (http://www.openssl.org/)" + * + * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY + * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * ==================================================================== + * + * This product includes cryptographic software written by Eric Young + * (eay@cryptsoft.com). This product includes software written by Tim + * Hudson (tjh@cryptsoft.com). + * + */ + + Original SSLeay License + ----------------------- + +/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) + * All rights reserved. + * + * This package is an SSL implementation written + * by Eric Young (eay@cryptsoft.com). + * The implementation was written so as to conform with Netscapes SSL. + * + * This library is free for commercial and non-commercial use as long as + * the following conditions are aheared to. The following conditions + * apply to all code found in this distribution, be it the RC4, RSA, + * lhash, DES, etc., code; not just the SSL code. The SSL documentation + * included with this distribution is covered by the same copyright terms + * except that the holder is Tim Hudson (tjh@cryptsoft.com). + * + * Copyright remains Eric Young's, and as such any Copyright notices in + * the code are not to be removed. + * If this package is used in a product, Eric Young should be given attribution + * as the author of the parts of the library used. + * This can be in the form of a textual message at program startup or + * in documentation (online or textual) provided with the package. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * "This product includes cryptographic software written by + * Eric Young (eay@cryptsoft.com)" + * The word 'cryptographic' can be left out if the rouines from the library + * being used are not cryptographic related :-). + * 4. If you include any Windows specific code (or a derivative thereof) from + * the apps directory (application code) you must include an acknowledgement: + * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + * + * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * + * The licence and distribution terms for any publically available version or + * derivative of this code cannot be changed. i.e. this code cannot simply be + * copied and put under another distribution licence + * [including the GNU Public Licence.] + */ + + + Title + OpenSSL-Universal + Type + PSGroupSpecifier + + + FooterText + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + Title + SignalCoreKit + Type + PSGroupSpecifier + + + FooterText + Copyright (c) 2010-2016 Sam Soffes, http://soff.es + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + Title + SAMKeychain + Type + PSGroupSpecifier + + + FooterText + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +## Runtime Library Exception to the Apache 2.0 License: ## + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. + + Title + SwiftProtobuf + Type + PSGroupSpecifier + + + FooterText + Generated by CocoaPods - https://cocoapods.org + Title + + Type + PSGroupSpecifier + + + StringsTable + Acknowledgements + Title + Acknowledgements + + diff --git a/Session/Meta/Settings.bundle/Root.plist b/Session/Meta/Settings.bundle/Root.plist index b1b6fea5d..2b5fc900a 100644 --- a/Session/Meta/Settings.bundle/Root.plist +++ b/Session/Meta/Settings.bundle/Root.plist @@ -7,54 +7,12 @@ PreferenceSpecifiers - Type - PSGroupSpecifier + File + Pods-GlobalDependencies-Session-settings-metadata Title - Group - - + Acknowledgements Type - PSTextFieldSpecifier - Title - Name - Key - name_preference - DefaultValue - - IsSecure - - KeyboardType - Alphabet - AutocapitalizationType - None - AutocorrectionType - No - - - Type - PSToggleSwitchSpecifier - Title - Enabled - Key - enabled_preference - DefaultValue - - - - Type - PSSliderSpecifier - Key - slider_preference - DefaultValue - 0.5 - MinimumValue - 0 - MaximumValue - 1 - MinimumValueImage - - MaximumValueImage - + PSChildPaneSpecifier diff --git a/Session/Meta/Settings.bundle/en.lproj/Root.strings b/Session/Meta/Settings.bundle/en.lproj/Root.strings deleted file mode 100644 index 8cd87b9d6b20c1fbf87bd4db3db267fca5ad4df9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 546 zcmaixOHRW;5JYRuDMndFh#Ua1V1d}N;sVAV2TO?uC3a9aJn*VxFrY}tnon0(S66#J z-d9>G>6W!ur(SDqlp`9nn~*(m%iWnv?yq`Qfp6XbK1?+om~~#r)ZnhkYQU_VbfjuT zHNn`CX<0sd*m1A}>&5sU$akD=GTXJ1e From 7a8941db5cdc68d3c73e6ba8cdffd5b8f2f66cbc Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 1 Sep 2023 16:16:13 +1000 Subject: [PATCH 49/50] Fixed a couple of config handling bugs Fixed an bug where config messages could be processed in the wrong order Tweaked the behaviour or removing threads (this would cause issues with future config-based settings changes that live on the thread getting lost) --- Session/Conversations/ConversationVC.swift | 2 +- .../UIContextualAction+Utilities.swift | 2 +- .../Database/Models/SessionThread.swift | 32 ++++++--- .../SessionUtil+Contacts.swift | 71 ++++++++----------- .../SessionUtil+UserProfile.swift | 2 +- .../QueryInterfaceRequest+Utilities.swift | 15 ++-- .../SessionUtil/SessionUtil.swift | 1 + 7 files changed, 68 insertions(+), 57 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 523cdd884..f5f1ded92 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -574,7 +574,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers !SessionUtil.conversationInConfig( threadId: threadId, threadVariant: viewModel.threadData.threadVariant, - visibleOnly: true + visibleOnly: false ) { Storage.shared.writeAsync { db in diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 85997e42a..3f26aa57c 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -164,7 +164,7 @@ public extension UIContextualAction { db, threadId: threadViewModel.threadId, threadVariant: threadViewModel.threadVariant, - groupLeaveType: .forced, + groupLeaveType: .silent, calledFromConfigHandling: false ) } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index cb8f84fc3..83e996d33 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -296,17 +296,18 @@ public extension SessionThread { calledFromConfigHandling: Bool ) throws { let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - let remainingThreadIds: [String] = threadIds.filter { $0 != currentUserPublicKey } + let remainingThreadIds: Set = threadIds.asSet().removing(currentUserPublicKey) switch (threadVariant, groupLeaveType) { - case (.contact, _): + case (.contact, .standard), (.contact, .silent): + // Clear any interactions for the deleted thread + _ = try Interaction + .filter(threadIds.contains(Interaction.Columns.threadId)) + .deleteAll(db) + // We need to custom handle the 'Note to Self' conversation (it should just be - // hidden rather than deleted + // hidden locally rather than deleted) if threadIds.contains(currentUserPublicKey) { - _ = try Interaction - .filter(Interaction.Columns.threadId == currentUserPublicKey) - .deleteAll(db) - _ = try SessionThread .filter(id: currentUserPublicKey) .updateAllAndConfig( @@ -314,17 +315,28 @@ public extension SessionThread { SessionThread.Columns.pinnedPriority.set(to: 0), SessionThread.Columns.shouldBeVisible.set(to: false) ) - return } + // Update any other threads to be hidden (don't want to actually delete the thread + // record in case it's settings get changed while it's not visible) + _ = try SessionThread + .filter(id: remainingThreadIds) + .updateAllAndConfig( + db, + calledFromConfig: calledFromConfig, + SessionThread.Columns.pinnedPriority.set(to: SessionUtil.hiddenPriority), + SessionThread.Columns.shouldBeVisible.set(to: false) + ) + + case (.contact, .forced): // If this wasn't called from config handling then we need to hide the conversation if !calledFromConfigHandling { try SessionUtil - .hide(db, contactIds: threadIds) + .remove(db, contactIds: remainingThreadIds) } _ = try SessionThread - .filter(ids: remainingThreadIds) + .filter(id: remainingThreadIds) .deleteAll(db) case (.legacyGroup, .standard), (.group, .standard): diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift index b6a8b86f0..745373060 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -138,46 +138,37 @@ internal extension SessionUtil { let threadExists: Bool = (threadInfo != nil) let updatedShouldBeVisible: Bool = SessionUtil.shouldBeVisible(priority: data.priority) - switch (updatedShouldBeVisible, threadExists) { - case (false, true): - SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: [sessionId]) - - try SessionThread - .deleteOrLeave( - db, - threadId: sessionId, - threadVariant: .contact, - groupLeaveType: .forced, - calledFromConfigHandling: true - ) - - case (true, false): - try SessionThread( - id: sessionId, - variant: .contact, - creationDateTimestamp: data.created, - shouldBeVisible: true, - pinnedPriority: data.priority - ).save(db) - - case (true, true): - let changes: [ConfigColumnAssignment] = [ - (threadInfo?.shouldBeVisible == updatedShouldBeVisible ? nil : - SessionThread.Columns.shouldBeVisible.set(to: updatedShouldBeVisible) - ), - (threadInfo?.pinnedPriority == data.priority ? nil : - SessionThread.Columns.pinnedPriority.set(to: data.priority) - ) - ].compactMap { $0 } - - try SessionThread - .filter(id: sessionId) - .updateAll( // Handling a config update so don't use `updateAllAndConfig` - db, - changes - ) - - case (false, false): break + /// If we are hiding the conversation then kick the user from it if it's currently open + if !updatedShouldBeVisible { + SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: [sessionId]) + } + + /// Create the thread if it doesn't exist, otherwise just update it's state + if !threadExists { + try SessionThread( + id: sessionId, + variant: .contact, + creationDateTimestamp: data.created, + shouldBeVisible: updatedShouldBeVisible, + pinnedPriority: data.priority + ).save(db) + } + else { + let changes: [ConfigColumnAssignment] = [ + (threadInfo?.shouldBeVisible == updatedShouldBeVisible ? nil : + SessionThread.Columns.shouldBeVisible.set(to: updatedShouldBeVisible) + ), + (threadInfo?.pinnedPriority == data.priority ? nil : + SessionThread.Columns.pinnedPriority.set(to: data.priority) + ) + ].compactMap { $0 } + + try SessionThread + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + changes + ) } } diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift index 4416eee82..f4017cec0 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -113,7 +113,7 @@ internal extension SessionUtil { db, threadId: userPublicKey, threadVariant: .contact, - groupLeaveType: .forced, + groupLeaveType: .silent, calledFromConfigHandling: true ) } diff --git a/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift b/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift index a7285604e..8e3482a31 100644 --- a/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift +++ b/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift @@ -52,14 +52,16 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAllAndConfig( _ db: Database, + calledFromConfig: Bool = false, _ assignments: ConfigColumnAssignment... ) throws -> Int { - return try updateAllAndConfig(db, assignments) + return try updateAllAndConfig(db, calledFromConfig: calledFromConfig, assignments) } @discardableResult func updateAllAndConfig( _ db: Database, + calledFromConfig: Bool = false, _ assignments: [ConfigColumnAssignment] ) throws -> Int { let targetAssignments: [ColumnAssignment] = assignments.map { $0.assignment } @@ -69,7 +71,7 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table return try self.updateAll(db, targetAssignments) } - return try self.updateAndFetchAllAndUpdateConfig(db, assignments).count + return try self.updateAndFetchAllAndUpdateConfig(db, calledFromConfig: calledFromConfig, assignments).count } // MARK: -- updateAndFetchAll @@ -77,21 +79,26 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table @discardableResult func updateAndFetchAllAndUpdateConfig( _ db: Database, + calledFromConfig: Bool = false, _ assignments: ConfigColumnAssignment... ) throws -> [RowDecoder] { - return try updateAndFetchAllAndUpdateConfig(db, assignments) + return try updateAndFetchAllAndUpdateConfig(db, calledFromConfig: calledFromConfig, assignments) } @discardableResult func updateAndFetchAllAndUpdateConfig( _ db: Database, + calledFromConfig: Bool = false, _ assignments: [ConfigColumnAssignment] ) throws -> [RowDecoder] { // First perform the actual updates let updatedData: [RowDecoder] = try self.updateAndFetchAll(db, assignments.map { $0.assignment }) // Then check if any of the changes could affect the config - guard SessionUtil.assignmentsRequireConfigUpdate(assignments) else { return updatedData } + guard + !calledFromConfig && + SessionUtil.assignmentsRequireConfigUpdate(assignments) + else { return updatedData } defer { // If we changed a column that requires a config update then we may as well automatically diff --git a/SessionMessagingKit/SessionUtil/SessionUtil.swift b/SessionMessagingKit/SessionUtil/SessionUtil.swift index a353e8ed1..363951df4 100644 --- a/SessionMessagingKit/SessionUtil/SessionUtil.swift +++ b/SessionMessagingKit/SessionUtil/SessionUtil.swift @@ -360,6 +360,7 @@ public enum SessionUtil { guard !publicKey.isEmpty else { throw MessageReceiverError.noThread } let groupedMessages: [ConfigDump.Variant: [SharedConfigMessage]] = messages + .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo } .grouped(by: \.kind.configDumpVariant) let needsPush: Bool = try groupedMessages From dc15586dd1166f527f619b92c631aa78bb6ab8b4 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 4 Sep 2023 11:20:15 +1000 Subject: [PATCH 50/50] Fixed a few silly build issues --- SessionMessagingKit/Database/Models/SessionThread.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 83e996d33..414c04ba4 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -312,6 +312,7 @@ public extension SessionThread { .filter(id: currentUserPublicKey) .updateAllAndConfig( db, + calledFromConfig: calledFromConfigHandling, SessionThread.Columns.pinnedPriority.set(to: 0), SessionThread.Columns.shouldBeVisible.set(to: false) ) @@ -320,10 +321,10 @@ public extension SessionThread { // Update any other threads to be hidden (don't want to actually delete the thread // record in case it's settings get changed while it's not visible) _ = try SessionThread - .filter(id: remainingThreadIds) + .filter(ids: remainingThreadIds) .updateAllAndConfig( db, - calledFromConfig: calledFromConfig, + calledFromConfig: calledFromConfigHandling, SessionThread.Columns.pinnedPriority.set(to: SessionUtil.hiddenPriority), SessionThread.Columns.shouldBeVisible.set(to: false) ) @@ -332,11 +333,11 @@ public extension SessionThread { // If this wasn't called from config handling then we need to hide the conversation if !calledFromConfigHandling { try SessionUtil - .remove(db, contactIds: remainingThreadIds) + .remove(db, contactIds: Array(remainingThreadIds)) } _ = try SessionThread - .filter(id: remainingThreadIds) + .filter(ids: remainingThreadIds) .deleteAll(db) case (.legacyGroup, .standard), (.group, .standard):