From ed9f4ea6c631da6867053316ede9659b8afe4b2e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 22 Apr 2022 18:47:11 +1000 Subject: [PATCH] Fixed a few closed group and job issues Fixed a few job migration issues Fixed an issue with the closed group key pair management (wasn't storing keys correctly) Refactored the OWSSound (now Preferences.Sound) Added the logic for the AttachmentDownloadJob and enabled jobs to be cascade deleted via interactions Optimised the HomeViewModel database observation query (fetch specific columns so changes outside those don't trigger updates) Updated to the latest GRDB (ran into a deadlock which should be fixed in a newer version) --- Podfile.lock | 4 +- Session.xcodeproj/project.pbxproj | 14 +- Session/Closed Groups/NewClosedGroupVC.swift | 11 +- Session/Meta/Signal-Bridging-Header.h | 1 - Session/Notifications/AppNotifications.swift | 17 +- Session/Notifications/SyncPushTokensJob.swift | 1 + .../UserNotificationsAdaptee.swift | 7 +- .../NotificationSettingsViewController.m | 4 +- .../Settings/OWSSoundSettingsViewController.h | 2 +- .../Settings/OWSSoundSettingsViewController.m | 39 +- Session/Shared/ConversationCell.swift | 5 +- SessionMessagingKit/Configuration.swift | 1 + .../_001_InitialSetupMigration.swift | 13 +- .../Migrations/_003_YDBToGRDBMigration.swift | 415 +++++++++--------- .../Database/Models/Attachment.swift | 92 ++-- .../Database/Models/ClosedGroupKeyPair.swift | 16 +- .../Database/Models/Interaction.swift | 81 +++- .../Models/InteractionAttachment.swift | 2 + SessionMessagingKit/Database/Models/Job.swift | 47 +- .../Database/Models/OpenGroup.swift | 1 - .../Database/Models/SessionThread.swift | 28 +- .../Jobs/AttachmentDownloadJob.swift | 166 ------- SessionMessagingKit/Jobs/JobRunner.swift | 113 +++-- SessionMessagingKit/Jobs/JobRunnerError.swift | 1 + .../Jobs/Types/AttachmentDownloadJob.swift | 309 +++++++++++++ .../Jobs/Types/DisappearingMessagesJob.swift | 15 +- .../Types/FailedAttachmentDownloadsJob.swift | 1 + .../Jobs/Types/FailedMessagesJob.swift | 1 + .../Jobs/Types/MessageReceiveJob.swift | 1 + .../Jobs/Types/MessageSendJob.swift | 1 + .../Jobs/Types/NotifyPushServerJob.swift | 8 +- .../Jobs/Types/SendReadReceiptsJob.swift | 1 + .../Meta/SessionMessagingKit.h | 1 - .../MessageReceiver+Decryption.swift | 4 + .../MessageReceiver+Handling.swift | 34 +- .../Sending & Receiving/MessageReceiver.swift | 2 +- .../MessageSender+ClosedGroups.swift | 74 ++-- .../Sending & Receiving/MessageSender.swift | 21 +- .../Sending & Receiving/Pollers/Poller.swift | 7 +- SessionMessagingKit/Utilities/Environment.h | 3 - SessionMessagingKit/Utilities/Environment.m | 4 - SessionMessagingKit/Utilities/OWSSounds.h | 79 ---- SessionMessagingKit/Utilities/OWSSounds.m | 365 --------------- SessionMessagingKit/Utilities/OWSSounds.swift | 15 - .../Utilities/Preferences.swift | 211 ++++++++- .../NSENotificationPresenter.swift | 3 +- .../Database/Models/Identity.swift | 76 +--- .../Database/Models/Setting.swift | 10 +- .../General/Array+Utilities.swift | 6 + SessionUtilitiesKit/General/LRUCache.swift | 26 -- SignalUtilitiesKit/Utilities/AppSetup.m | 3 - 51 files changed, 1188 insertions(+), 1174 deletions(-) delete mode 100644 SessionMessagingKit/Jobs/AttachmentDownloadJob.swift create mode 100644 SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift delete mode 100644 SessionMessagingKit/Utilities/OWSSounds.h delete mode 100644 SessionMessagingKit/Utilities/OWSSounds.m delete mode 100644 SessionMessagingKit/Utilities/OWSSounds.swift diff --git a/Podfile.lock b/Podfile.lock index 9f0a321d1..81d738229 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -27,7 +27,7 @@ PODS: - DifferenceKit/Core (1.2.0) - DifferenceKit/UIKitExtension (1.2.0): - DifferenceKit/Core - - GRDB.swift/SQLCipher (5.19.0): + - GRDB.swift/SQLCipher (5.23.0): - SQLCipher (>= 3.4.0) - Mantle (2.1.0): - Mantle/extobjc (= 2.1.0) @@ -203,7 +203,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 - GRDB.swift: c00ff42d3cffbe90145fb4e364e26a099f997142 + GRDB.swift: e4a950fe99d113ea5d24571d49eaae0062303c14 Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index dbd32e632..aaea0791e 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -121,7 +121,6 @@ 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; }; 768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; }; 76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; }; - 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E1271E743B00848B49 /* OWSSounds.swift */; }; 7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */; }; 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */; }; 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */; }; @@ -213,8 +212,6 @@ B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */; }; B8856E1A256F1700001CE70E /* OWSMath.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB14255A580800E217F9 /* OWSMath.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDAB9255A580100E217F9 /* OWSStorage+Subclass.h */; settings = {ATTRIBUTES = (Public, ); }; }; - B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF28B255B6D86007E1867 /* OWSSounds.m */; }; - B8856E9D256F1C3D001CE70E /* OWSSounds.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF288255B6D85007E1867 /* OWSSounds.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF308255B6DBE007E1867 /* OWSPreferences.m */; }; B8856ED7256F1EB4001CE70E /* OWSPreferences.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */; settings = {ATTRIBUTES = (Public, ); }; }; B886B4A72398B23E00211ABE /* QRCodeVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A62398B23E00211ABE /* QRCodeVC.swift */; }; @@ -1136,7 +1133,6 @@ 768A1A2A17FC9CD300E00ED8 /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = usr/lib/libz.dylib; sourceTree = SDKROOT; }; 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; 7ABE4694B110C1BBCB0E46A2 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; - 7B1581E1271E743B00848B49 /* OWSSounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSSounds.swift; sourceTree = ""; }; 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = ""; }; 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Timeout.swift"; sourceTree = ""; }; 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+MainThread.swift"; sourceTree = ""; }; @@ -1574,8 +1570,6 @@ C38EF284255B6D84007E1867 /* AppSetup.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppSetup.h; path = SignalUtilitiesKit/Utilities/AppSetup.h; sourceTree = SOURCE_ROOT; }; C38EF286255B6D85007E1867 /* VersionMigrations.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = VersionMigrations.m; path = SignalUtilitiesKit/Utilities/VersionMigrations.m; sourceTree = SOURCE_ROOT; }; C38EF287255B6D85007E1867 /* AppSetup.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppSetup.m; path = SignalUtilitiesKit/Utilities/AppSetup.m; sourceTree = SOURCE_ROOT; }; - C38EF288255B6D85007E1867 /* OWSSounds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSSounds.h; path = SessionMessagingKit/Utilities/OWSSounds.h; sourceTree = SOURCE_ROOT; }; - C38EF28B255B6D86007E1867 /* OWSSounds.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSSounds.m; path = SessionMessagingKit/Utilities/OWSSounds.m; sourceTree = SOURCE_ROOT; }; C38EF2A2255B6D93007E1867 /* Identicon+ObjC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Identicon+ObjC.swift"; path = "SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift"; sourceTree = SOURCE_ROOT; }; C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PlaceholderIcon.swift; path = "SignalUtilitiesKit/Profile Pictures/PlaceholderIcon.swift"; sourceTree = SOURCE_ROOT; }; C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProfilePictureView.swift; path = "SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift"; sourceTree = SOURCE_ROOT; }; @@ -2865,7 +2859,6 @@ C352A3922557883D00338F3E /* JobDelegate.swift */, C352A3882557876500338F3E /* JobQueue.swift */, C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */, - C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, ); path = Jobs; sourceTree = ""; @@ -3244,9 +3237,6 @@ C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */, C38EF308255B6DBE007E1867 /* OWSPreferences.m */, FDF0B75D280AAF35004C14C5 /* Preferences.swift */, - C38EF288255B6D85007E1867 /* OWSSounds.h */, - C38EF28B255B6D86007E1867 /* OWSSounds.m */, - 7B1581E1271E743B00848B49 /* OWSSounds.swift */, C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, FD09797327FAB3E200936362 /* ProfileManager.swift */, @@ -3821,6 +3811,7 @@ C352A31225574F5200338F3E /* MessageReceiveJob.swift */, C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */, FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */, + C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, ); path = Types; sourceTree = ""; @@ -3978,7 +3969,6 @@ C3A3A15F256E1BB4004D228D /* ProtoUtils.h in Headers */, C3A3A145256E1B49004D228D /* OWSMediaGalleryFinder.h in Headers */, B8856E33256F18D5001CE70E /* OWSStorage+Subclass.h in Headers */, - B8856E9D256F1C3D001CE70E /* OWSSounds.h in Headers */, C32C5E64256DDFD6003C73A2 /* OWSStorage.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4941,7 +4931,6 @@ FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, FD09799727FFA84A00936362 /* RecipientState.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, - 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, @@ -5025,7 +5014,6 @@ C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, - B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */, C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */, C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 40d81b645..2558f1975 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -173,20 +173,21 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat let selectedContacts = self.selectedContacts let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in - var promise: Promise! - Storage.writeSync { transaction in - promise = MessageSender.createClosedGroup(name: name, members: selectedContacts, transaction: transaction) + let promise: Promise = GRDBStorage.shared.write { db in + try MessageSender.createClosedGroup(db, name: name, members: selectedContacts) } + let _ = promise.done(on: DispatchQueue.main) { thread in GRDBStorage.shared.write { db in - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() } self?.presentingViewController?.dismiss(animated: true, completion: nil) SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false) } - promise.catch(on: DispatchQueue.main) { _ in + promise.catch(on: DispatchQueue.main) { [weak self] _ in self?.dismiss(animated: true, completion: nil) // Dismiss the loader + let title = "Couldn't Create Group" let message = "Please check your internet connection and try again." let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index e785154a7..0eb31c11d 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -47,7 +47,6 @@ #import #import #import -#import #import #import #import diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index b067296a7..40964537a 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -95,8 +95,8 @@ protocol NotificationPresenterAdaptee: AnyObject { func registerNotificationSettings() -> Promise - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?) - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?) + func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?) + func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?) func cancelNotifications(threadId: String) func cancelNotifications(identifiers: [String]) @@ -225,15 +225,11 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { ) ) } - - default: - notificationTitle = "Session" } switch previewType { case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody case .nameAndPreview: notificationBody = messageText - default: notificationBody = NotificationStrings.incomingMessageBody } // If it's a message request then overwrite the body to be something generic (only show a notification @@ -257,7 +253,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { in: (notificationBody ?? ""), threadId: thread.id ) - let sound = self.requestSound(thread: thread) + let sound: Preferences.Sound? = self.requestSound(thread: thread) self.adaptee.notify( category: category, @@ -286,7 +282,8 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { ] DispatchQueue.main.async { - let sound = self.requestSound(thread: thread) + let sound: Preferences.Sound? = self.requestSound(thread: thread) + self.adaptee.notify( category: .errorMessage, title: notificationTitle, @@ -318,12 +315,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { var mostRecentNotifications = TruncatedList(maxLength: kAudioNotificationsThrottleCount) - private func requestSound(thread: SessionThread) -> OWSSound? { + private func requestSound(thread: SessionThread) -> Preferences.Sound? { guard checkIfShouldPlaySound() else { return nil } - return OWSSounds.notificationSound(forThreadId: thread.id) + return thread.notificationSound } private func checkIfShouldPlaySound() -> Bool { diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 28f41d7e3..fabce3628 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -10,6 +10,7 @@ import SessionUtilitiesKit public enum SyncPushTokensJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 7e9daaa35..23349fb82 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -5,6 +5,7 @@ import Foundation import UserNotifications import PromiseKit +import SessionMessagingKit class UserNotificationConfig { @@ -85,12 +86,12 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { } } - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?) { + func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?) { AssertIsOnMainThread() notify(category: category, title: title, body: body, userInfo: userInfo, sound: sound, replacingIdentifier: nil) } - func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?) { + func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?) { AssertIsOnMainThread() let content = UNMutableNotificationContent() @@ -103,7 +104,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { isBackgroudPoll = replacingIdentifier == threadIdentifier } let isAppActive = UIApplication.shared.applicationState == .active - if let sound = sound, sound != OWSSound.none { + if let sound = sound, sound != .none { content.sound = sound.notificationSound(isQuiet: isAppActive) } diff --git a/Session/Settings/NotificationSettingsViewController.m b/Session/Settings/NotificationSettingsViewController.m index d087b8f67..ce0099053 100644 --- a/Session/Settings/NotificationSettingsViewController.m +++ b/Session/Settings/NotificationSettingsViewController.m @@ -9,7 +9,7 @@ #import "OWSSoundSettingsViewController.h" #import #import -#import +#import #import #import "Session-Swift.h" @@ -66,7 +66,7 @@ addItem:[OWSTableItem disclosureItemWithText: NSLocalizedString(@"SETTINGS_ITEM_NOTIFICATION_SOUND", @"Label for settings view that allows user to change the notification sound.") - detailText:[OWSSounds displayNameForSound:[OWSSounds globalNotificationSound]] + detailText:[SMKSound displayNameFor:[SMKSound defaultNotificationSound]] accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"message_sound") actionBlock:^{ OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new]; diff --git a/Session/Settings/OWSSoundSettingsViewController.h b/Session/Settings/OWSSoundSettingsViewController.h index 27b89e488..9a86798bc 100644 --- a/Session/Settings/OWSSoundSettingsViewController.h +++ b/Session/Settings/OWSSoundSettingsViewController.h @@ -12,7 +12,7 @@ NS_ASSUME_NONNULL_BEGIN // This property is optional. If it is not set, we are // editing the global notification sound. -@property (nonatomic, nullable) TSThread *thread; +@property (nonatomic, nullable) NSString *threadId; @end diff --git a/Session/Settings/OWSSoundSettingsViewController.m b/Session/Settings/OWSSoundSettingsViewController.m index 4c086577a..77d33e6b6 100644 --- a/Session/Settings/OWSSoundSettingsViewController.m +++ b/Session/Settings/OWSSoundSettingsViewController.m @@ -5,7 +5,7 @@ #import "OWSSoundSettingsViewController.h" #import #import -#import +#import #import #import #import "Session-Swift.h" @@ -16,7 +16,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL isDirty; -@property (nonatomic) OWSSound currentSound; +@property (nonatomic) NSInteger currentSound; @property (nonatomic, nullable) OWSAudioPlayer *audioPlayer; @@ -32,9 +32,8 @@ NS_ASSUME_NONNULL_BEGIN [self setTitle:NSLocalizedString(@"SETTINGS_ITEM_NOTIFICATION_SOUND", @"Label for settings view that allows user to change the notification sound.")]; - self.currentSound - = (self.thread ? [OWSSounds notificationSoundForThread:self.thread] : [OWSSounds globalNotificationSound]); - + self.currentSound = [SMKSound notificationSoundFor:self.threadId]; + [self updateTableContents]; [self updateNavigationItems]; @@ -85,33 +84,34 @@ NS_ASSUME_NONNULL_BEGIN soundsSection.headerTitle = NSLocalizedString( @"NOTIFICATIONS_SECTION_SOUNDS", @"Label for settings UI that allows user to change the notification sound."); - NSArray *allSounds = [OWSSounds allNotificationSounds]; + NSArray *allSounds = [SMKSound notificationSounds]; for (NSNumber *nsValue in allSounds) { - OWSSound sound = (OWSSound)nsValue.intValue; + NSInteger sound = nsValue.integerValue; OWSTableItem *item; NSString *soundLabelText = ^{ - NSString *baseName = [OWSSounds displayNameForSound:sound]; - if (sound == OWSSound_Note) { + NSString *baseName = [SMKSound displayNameFor:sound]; + if ([SMKSound isNote:sound]) { NSString *noteStringFormat = NSLocalizedString(@"SETTINGS_AUDIO_DEFAULT_TONE_LABEL_FORMAT", @"Format string for the default 'Note' sound. Embeds the system {{sound name}}."); return [NSString stringWithFormat:noteStringFormat, baseName]; - } else { - return [OWSSounds displayNameForSound:sound]; + } + else { + return [SMKSound displayNameFor:sound]; } }(); if (sound == self.currentSound) { item = [OWSTableItem checkmarkItemWithText:soundLabelText - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [OWSSounds displayNameForSound:sound]) + accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [SMKSound displayNameFor:sound]) actionBlock:^{ [weakSelf soundWasSelected:sound]; }]; } else { item = [OWSTableItem actionItemWithText:soundLabelText - accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [OWSSounds displayNameForSound:sound]) + accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, [SMKSound displayNameFor:sound]) actionBlock:^{ [weakSelf soundWasSelected:sound]; }]; @@ -126,10 +126,10 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Events -- (void)soundWasSelected:(OWSSound)sound +- (void)soundWasSelected:(NSInteger)sound { [self.audioPlayer stop]; - self.audioPlayer = [OWSSounds audioPlayerForSound:sound audioBehavior:OWSAudioBehavior_Playback]; + self.audioPlayer = [SMKSound audioPlayerFor:sound audioBehavior:OWSAudioBehavior_Playback]; // Suppress looping in this view. self.audioPlayer.isLooping = NO; [self.audioPlayer play]; @@ -153,10 +153,11 @@ NS_ASSUME_NONNULL_BEGIN - (void)saveWasPressed:(id)sender { - if (self.thread) { - [OWSSounds setNotificationSound:self.currentSound forThread:self.thread]; - } else { - [OWSSounds setGlobalNotificationSound:self.currentSound]; + if (self.threadId) { + [SMKSound setNotificationSound:self.currentSound forThreadId:self.threadId]; + } + else { + [SMKSound setGlobalNotificationSound:self.currentSound]; } [self.audioPlayer stop]; diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 81ff07352..b72e839a3 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -214,9 +214,8 @@ final class ConversationCell : UITableViewCell { // MARK: - Updating for search results private func updateForSearchResult(_ threadViewModel: ThreadViewModel) { - AssertIsOnMainThread() - guard let thread = threadViewModel?.threadRecord else { return } - profilePictureView.update(for: thread) + GRDBStorage.shared.read { db in profilePictureView.update(db, thread: threadViewModel.thread) } + isPinnedIcon.isHidden = true unreadCountView.isHidden = true hasMentionView.isHidden = true diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index d69b554aa..cb62884f2 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -37,6 +37,7 @@ public enum SNMessagingKit { // Just to make the external API nice 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) SNMessagingKitConfiguration.shared = SNMessagingKitConfiguration(storage: storage) } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 4a096cf4a..bb4cc6518 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -79,12 +79,17 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: ClosedGroupKeyPair.self) { t in - t.column(.publicKey, .text) + t.column(.threadId, .text) .notNull() .indexed() // Quicker querying .references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted + t.column(.publicKey, .blob).notNull() t.column(.secretKey, .blob).notNull() - t.column(.receivedTimestamp, .double).notNull() + t.column(.receivedTimestamp, .double) + .notNull() + .indexed() // Quicker querying + + t.uniqueKey([.publicKey, .secretKey, .receivedTimestamp]) } try db.create(table: OpenGroup.self) { t in @@ -217,6 +222,7 @@ enum _001_InitialSetupMigration: Migration { t.column(.creationTimestamp, .double) t.column(.sourceFilename, .text) t.column(.downloadUrl, .text) + t.column(.localRelativeFilePath, .text) t.column(.width, .integer) t.column(.height, .integer) t.column(.encryptionKey, .blob) @@ -291,6 +297,9 @@ enum _001_InitialSetupMigration: Migration { t.column(.threadId, .text) .indexed() // Quicker querying .references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted + t.column(.interactionId, .text) + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted t.column(.details, .blob) } } diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 7868ba662..e14d89ff9 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -18,10 +18,11 @@ enum _003_YDBToGRDBMigration: Migration { var contacts: Set = [] var contactThreadIds: Set = [] + var legacyThreadIdToIdMap: [String: String] = [:] var threads: Set = [] var disappearingMessagesConfiguration: [String: Legacy.DisappearingMessagesConfiguration] = [:] - var closedGroupKeys: [String: (timestamp: TimeInterval, keys: SessionUtilitiesKit.Legacy.KeyPair)] = [:] + var closedGroupKeys: [String: [TimeInterval: SessionUtilitiesKit.Legacy.KeyPair]] = [:] var closedGroupName: [String: String] = [:] var closedGroupFormation: [String: UInt64] = [:] var closedGroupModel: [String: TSGroupModel] = [:] @@ -67,10 +68,11 @@ enum _003_YDBToGRDBMigration: Migration { .asType(Legacy.DisappearingMessagesConfiguration.self) .defaulting(to: Legacy.DisappearingMessagesConfiguration.defaultWith(threadId)) - // Process the interactions - // Process group-specific info - guard let groupThread: TSGroupThread = thread as? TSGroupThread else { return } + guard let groupThread: TSGroupThread = thread as? TSGroupThread else { + legacyThreadIdToIdMap[threadId] = threadId.substring(from: Legacy.contactThreadPrefix.count) + return + } if groupThread.isClosedGroup { // The old threadId for closed groups was in the below format, we don't @@ -96,6 +98,7 @@ enum _003_YDBToGRDBMigration: Migration { let keyCollection: String = "\(Legacy.closedGroupKeyPairPrefix)\(publicKey)" + legacyThreadIdToIdMap[threadId] = publicKey closedGroupName[threadId] = groupThread.name(with: transaction) closedGroupModel[threadId] = groupThread.groupModel closedGroupFormation[threadId] = ((transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64) ?? 0) @@ -109,7 +112,8 @@ enum _003_YDBToGRDBMigration: Migration { return } - closedGroupKeys[threadId] = (timestamp, keyPair) + closedGroupKeys[threadId] = (closedGroupKeys[threadId] ?? [:]) + .setting(timestamp, keyPair) } } else if groupThread.isOpenGroup { @@ -119,6 +123,10 @@ enum _003_YDBToGRDBMigration: Migration { return } + legacyThreadIdToIdMap[threadId] = OpenGroup.idFor( + room: openGroup.room, + server: openGroup.server + ) openGroupInfo[threadId] = openGroup openGroupUserCount[threadId] = ((transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupUserCountCollection) as? Int) ?? 0) openGroupImage[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupImageCollection) as? Data @@ -163,101 +171,6 @@ enum _003_YDBToGRDBMigration: Migration { .union(timestampsMs) } - /* - guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else { - owsFailDebug("Could not load view.") - return - } - guard let group = group else { - owsFailDebug("No group.") - return - } - - // Deserializing interactions is expensive, so we only - // do that when necessary. - let sortIdForItemId: (String) -> UInt64? = { (itemId) in - guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else { - owsFailDebug("Could not load interaction.") - return nil - } - return interaction.sortId - } - self.viewName = TSMessageDatabaseViewExtensionName - self.group = group - // If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot. - // If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot. - var newItemIds = [ItemId]() - var canLoadMore = false - let desiredLength = self.desiredLength - // Not all items "count" towards the desired length. On an initial load, all items count. Subsequently, - // only items above the pivot count. - var afterPivotCount: UInt = 0 - var beforePivotCount: UInt = 0 - // (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block; - view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in - let itemId = key - - // Load "uncounted" items after the pivot if possible. - // - // As an optimization, we can skip this check (which requires - // deserializing the interaction) if beforePivotCount is non-zero, - // e.g. after we "pass" the pivot. - if beforePivotCount == 0, - let pivotSortId = self.pivotSortId { - if let sortId = sortIdForItemId(itemId) { - let isAfterPivot = sortId > pivotSortId - if isAfterPivot { - newItemIds.append(itemId) - afterPivotCount += 1 - return - } - } else { - owsFailDebug("Could not determine sort id for interaction: \(itemId)") - } - } - - // Load "counted" items unless the load window overflows. - if beforePivotCount >= desiredLength { - // Overflow - canLoadMore = true - stop.pointee = true - } else { - newItemIds.append(itemId) - beforePivotCount += 1 - } - } - NSMutableSet *interactionIds = [NSMutableSet new]; - [self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - NSMutableArray *interactions = [NSMutableArray new]; - - YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName]; - OWSAssertDebug(viewTransaction); - for (NSString *uniqueId in loadedUniqueIds) { - TSInteraction *_Nullable interaction = - [TSInteraction fetchObjectWithUniqueID:uniqueId transaction:transaction]; - if (!interaction) { - OWSFailDebug(@"missing interaction in message mapping: %@.", uniqueId); - hasError = YES; - continue; - } - if (!interaction.uniqueId) { - OWSFailDebug(@"invalid interaction in message mapping: %@.", interaction); - hasError = YES; - continue; - } - [interactions addObject:interaction]; - if ([interactionIds containsObject:interaction.uniqueId]) { - OWSFailDebug(@"Duplicate interaction: %@", interaction.uniqueId); - continue; - } - [interactionIds addObject:interaction.uniqueId]; - } - - for (TSInteraction *interaction in interactions) { - tryToAddViewItem(interaction, transaction); - } - }]; - */ } // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here @@ -311,11 +224,19 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Insert Threads print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - Start") - var legacyThreadIdToIdMap: [String: String] = [:] var legacyInteractionToIdMap: [String: Int64] = [:] var legacyInteractionIdentifierToIdMap: [String: Int64] = [:] + var legacyInteractionIdentifierToIdFallbackMap: [String: Int64] = [:] + var legacyAttachmentToIdMap: [String: String] = [:] - func identifier(for threadId: String, sentTimestamp: UInt64, recipients: [String], destination: Message.Destination? = nil) -> String { + func identifier( + for threadId: String, + sentTimestamp: UInt64, + recipients: [String], + destination: Message.Destination?, + variant: Interaction.Variant?, + useFallback: Bool + ) -> String { let recipientString: String = { if let destination: Message.Destination = destination { switch destination { @@ -328,41 +249,34 @@ enum _003_YDBToGRDBMigration: Migration { }() return [ - "\(sentTimestamp)", + (useFallback ? + // Fallback to seconds-based accuracy (instead of milliseconds) + String("\(sentTimestamp)".prefix("\(Int(Date().timeIntervalSince1970))".count)) : + "\(sentTimestamp)" + ), + (useFallback ? variant.map { "\($0)" } : nil), recipientString, threadId ] + .compactMap { $0 } .joined(separator: "-") } try threads.forEach { thread in - guard let legacyThreadId: String = thread.uniqueId else { return } + guard + let legacyThreadId: String = thread.uniqueId, + let threadId: String = legacyThreadIdToIdMap[legacyThreadId] + else { + SNLog("[Migration Error] Unable to migrate thread with no id mapping") + throw GRDBStorageError.migrationFailed + } - let id: String - let variant: SessionThread.Variant + let threadVariant: SessionThread.Variant let notificationMode: SessionThread.NotificationMode switch thread { case let groupThread as TSGroupThread: - if groupThread.isOpenGroup { - guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThreadId] else { - SNLog("[Migration Error] Open group missing required data") - throw GRDBStorageError.migrationFailed - } - - id = openGroup.id - variant = .openGroup - } - else { - guard let publicKey: Data = closedGroupKeys[legacyThreadId]?.keys.publicKey else { - SNLog("[Migration Error] Closed group missing public key") - throw GRDBStorageError.migrationFailed - } - - id = publicKey.toHexString() - variant = .closedGroup - } - + threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup) notificationMode = (thread.isMuted ? .none : (groupThread.isOnlyNotifyingForMentions ? .mentionsOnly : @@ -371,17 +285,14 @@ enum _003_YDBToGRDBMigration: Migration { ) default: - id = legacyThreadId.substring(from: Legacy.contactThreadPrefix.count) - variant = .contact + threadVariant = .contact notificationMode = (thread.isMuted ? .none : .all) } try autoreleasepool { - legacyThreadIdToIdMap[thread.uniqueId ?? ""] = id - try SessionThread( - id: id, - variant: variant, + id: threadId, + variant: threadVariant, creationDateTimestamp: thread.creationDate.timeIntervalSince1970, shouldBeVisible: thread.shouldBeVisible, isPinned: thread.isPinned, @@ -391,9 +302,9 @@ enum _003_YDBToGRDBMigration: Migration { ).insert(db) // Disappearing Messages Configuration - if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[id] { + if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[threadId] { try DisappearingMessagesConfiguration( - threadId: id, + threadId: threadId, isEnabled: config.isEnabled, durationSeconds: TimeInterval(config.durationSeconds) ).insert(db) @@ -402,7 +313,7 @@ enum _003_YDBToGRDBMigration: Migration { // Closed Groups if (thread as? TSGroupThread)?.isClosedGroup == true { guard - let keyInfo = closedGroupKeys[legacyThreadId], + let legacyKeys = closedGroupKeys[legacyThreadId], let name: String = closedGroupName[legacyThreadId], let groupModel: TSGroupModel = closedGroupModel[legacyThreadId], let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId] @@ -412,20 +323,23 @@ enum _003_YDBToGRDBMigration: Migration { } try ClosedGroup( - threadId: id, + threadId: threadId, name: name, formationTimestamp: TimeInterval(formationTimestamp) ).insert(db) - try ClosedGroupKeyPair( - publicKey: keyInfo.keys.publicKey.toHexString(), - secretKey: keyInfo.keys.privateKey, - receivedTimestamp: keyInfo.timestamp - ).insert(db) + try legacyKeys.forEach { timestamp, legacyKeys in + try ClosedGroupKeyPair( + threadId: threadId, + publicKey: legacyKeys.publicKey, + secretKey: legacyKeys.privateKey, + receivedTimestamp: timestamp + ).insert(db) + } try groupModel.groupMemberIds.forEach { memberId in try GroupMember( - groupId: id, + groupId: threadId, profileId: memberId, role: .standard ).insert(db) @@ -433,7 +347,7 @@ enum _003_YDBToGRDBMigration: Migration { try groupModel.groupAdminIds.forEach { adminId in try GroupMember( - groupId: id, + groupId: threadId, profileId: adminId, role: .admin ).insert(db) @@ -441,7 +355,7 @@ enum _003_YDBToGRDBMigration: Migration { try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in try GroupMember( - groupId: id, + groupId: threadId, profileId: zombieId, role: .zombie ).insert(db) @@ -601,7 +515,7 @@ enum _003_YDBToGRDBMigration: Migration { // Insert the data let interaction: Interaction = try Interaction( serverHash: serverHash, - threadId: id, + threadId: threadId, authorId: authorId, variant: variant, body: body, @@ -624,12 +538,25 @@ enum _003_YDBToGRDBMigration: Migration { // Store the interactionId in the lookup map to simplify job creation later let legacyIdentifier: String = identifier( - for: legacyInteraction.uniqueThreadId, + for: threadId, sentTimestamp: legacyInteraction.timestamp, - recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []) + recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []), + destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil), + variant: variant, + useFallback: false ) + let legacyIdentifierFallback: String = identifier( + for: threadId, + sentTimestamp: legacyInteraction.timestamp, + recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? []), + destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil), + variant: variant, + useFallback: true + ) + legacyInteractionToIdMap[legacyInteraction.uniqueId ?? ""] = interactionId legacyInteractionIdentifierToIdMap[legacyIdentifier] = interactionId + legacyInteractionIdentifierToIdFallbackMap[legacyIdentifierFallback] = interactionId // Handle the recipient states @@ -678,12 +605,24 @@ enum _003_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } + // Setup the attachment and add it to the lookup (if it exists) + let attachmentId: String? = try attachmentId( + db, + for: quoteAttachmentId, + attachments: attachments + ) + + if let quoteAttachmentId: String = quoteAttachmentId, let attachmentId: String = attachmentId { + legacyAttachmentToIdMap[quoteAttachmentId] = attachmentId + } + + // Create the quote try Quote( interactionId: interactionId, authorId: quotedMessage.authorId, timestampMs: Int64(quotedMessage.timestamp), body: quotedMessage.body, - attachmentId: try attachmentId(db, for: quoteAttachmentId, attachments: attachments) + attachmentId: attachmentId ).insert(db) } @@ -699,6 +638,17 @@ enum _003_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } + // Setup the attachment and add it to the lookup (if it exists) + let attachmentId: String? = try attachmentId( + db, + for: linkPreview.imageAttachmentId, + attachments: attachments + ) + + if let legacyAttachmentId: String = linkPreview.imageAttachmentId, let attachmentId: String = attachmentId { + legacyAttachmentToIdMap[legacyAttachmentId] = attachmentId + } + // Note: It's possible for there to be duplicate values here so we use 'save' // instead of insert (ie. upsert) try LinkPreview( @@ -706,13 +656,12 @@ enum _003_YDBToGRDBMigration: Migration { timestamp: timestamp, variant: linkPreviewVariant, title: linkPreview.title, - attachmentId: try attachmentId(db, for: linkPreview.imageAttachmentId, attachments: attachments) + attachmentId: attachmentId ).save(db) } // Handle any attachments - print("ASD \(attachmentIds)") try attachmentIds.forEach { legacyAttachmentId in guard let attachmentId: String = try attachmentId(db, for: legacyAttachmentId, interactionVariant: variant, attachments: attachments) else { // TODO: Is it possible to hit this case if an interaction hasn't been viewed? @@ -720,10 +669,13 @@ enum _003_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } + // Link the attachment to the interaction and add to the id lookup try InteractionAttachment( interactionId: interactionId, attachmentId: attachmentId ).insert(db) + + legacyAttachmentToIdMap[legacyAttachmentId] = attachmentId } } } @@ -760,6 +712,31 @@ enum _003_YDBToGRDBMigration: Migration { var attachmentUploadJobs: Set = [] var attachmentDownloadJobs: Set = [] + // Map the Legacy types for the NSKeyedUnarchiver + NSKeyedUnarchiver.setClass( + Legacy.NotifyPNServerJob.self, + forClassName: "SessionMessagingKit.NotifyPNServerJob" + ) + NSKeyedUnarchiver.setClass( + Legacy.NotifyPNServerJob.SnodeMessage.self, + forClassName: "SessionSnodeKit.SnodeMessage" + ) + NSKeyedUnarchiver.setClass( + Legacy.MessageSendJob.self, + forClassName: "SessionMessagingKit.SNMessageSendJob" + ) + NSKeyedUnarchiver.setClass( + Legacy.MessageReceiveJob.self, + forClassName: "SessionMessagingKit.MessageReceiveJob" + ) + NSKeyedUnarchiver.setClass( + Legacy.AttachmentUploadJob.self, + forClassName: "SessionMessagingKit.AttachmentUploadJob" + ) + NSKeyedUnarchiver.setClass( + Legacy.AttachmentDownloadJob.self, + forClassName: "SessionMessagingKit.AttachmentDownloadJob" + ) Storage.read { transaction in transaction.enumerateRows(inCollection: Legacy.notifyPushServerJobCollection) { _, object, _, _ in guard let job = object as? Legacy.NotifyPNServerJob else { return } @@ -798,16 +775,15 @@ enum _003_YDBToGRDBMigration: Migration { variant: .notifyPushServer, behaviour: .runOnce, nextRunTimestamp: 0, - details: String( - data: try JSONEncoder().encode( - SnodeMessage( - recipient: legacyJob.message.recipient, - data: legacyJob.message.data.description, // TODO: Test this (looks like it should be fine) - ttl: legacyJob.message.ttl, - timestampMs: legacyJob.message.timestamp - ) - ), - encoding: .utf8 + details: NotifyPushServerJob.Details( + message: SnodeMessage( + recipient: legacyJob.message.recipient, + // Note: The legacy type had 'LosslessStringConvertible' so we need + // to use '.description' to get it as a basic string + data: legacyJob.message.data.description, + ttl: legacyJob.message.ttl, + timestampMs: legacyJob.message.timestamp + ) ) )?.inserted(db) } @@ -823,20 +799,24 @@ enum _003_YDBToGRDBMigration: Migration { return } + // We need to extract the `threadId` from the legacyJob data as the new + // MessageReceiveJob requires it for multi-threading and garbage collection purposes + guard let envelope: SNProtoEnvelope = try? SNProtoEnvelope.parseData(legacyJob.data) else { + return + } + + let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) + _ = try Job( failureCount: legacyJob.failureCount, variant: .messageReceive, behaviour: .runOnce, nextRunTimestamp: 0, - details: String( - data: try JSONEncoder().encode( - MessageReceiveJob.Details( - data: legacyJob.data, - serverHash: legacyJob.serverHash, - isBackgroundPoll: legacyJob.isBackgroundPoll - ) - ), - encoding: .utf8 + threadId: threadId, + details: MessageReceiveJob.Details( + data: legacyJob.data, + serverHash: legacyJob.serverHash, + isBackgroundPoll: legacyJob.isBackgroundPoll ) )?.inserted(db) } @@ -848,26 +828,68 @@ enum _003_YDBToGRDBMigration: Migration { try autoreleasepool { try messageSendJobs.forEach { legacyJob in - let legacyIdentifier: String = identifier( - for: (legacyJob.message.threadID ?? ""), - sentTimestamp: (legacyJob.message.sentTimestamp ?? 0), - recipients: (legacyJob.message.recipient.map { [$0] } ?? []), - destination: legacyJob.destination - ) - - // Fetch the interaction this job should be associated with + // Fetch the threadId and interactionId this job should be associated with + let threadId: String = { + switch legacyJob.destination { + case .contact(let publicKey): return publicKey + case .closedGroup(let groupPublicKey): return groupPublicKey + case .openGroupV2(let room, let server): + return OpenGroup.idFor(room: room, server: server) + + case .openGroup: return "" + } + }() + let interactionId: Int64? = { + // The 'Legacy.Job' 'id' value was "(timestamp)(num jobs for this timestamp)" + // so we can reverse-engineer an approximate timestamp by extracting it from + // the id (this value is unlikely to match exactly though) + let fallbackTimestamp: UInt64 = legacyJob.id + .map { UInt64($0.prefix("\(Int(Date().timeIntervalSince1970 * 1000))".count)) } + .defaulting(to: 0) + let legacyIdentifier: String = identifier( + for: threadId, + sentTimestamp: (legacyJob.message.sentTimestamp ?? fallbackTimestamp), + recipients: (legacyJob.message.recipient.map { [$0] } ?? []), + destination: legacyJob.destination, + variant: nil, + useFallback: false + ) + + if let matchingId: Int64 = legacyInteractionIdentifierToIdMap[legacyIdentifier] { + return matchingId + } + + // If we didn't find the correct interaction then we need to try the "fallback" + // identifier which is less accurate (during testing this only happened for + // 'ExpirationTimerUpdate' send jobs) + let fallbackIdentifier: String = identifier( + for: threadId, + sentTimestamp: (legacyJob.message.sentTimestamp ?? fallbackTimestamp), + recipients: (legacyJob.message.recipient.map { [$0] } ?? []), + destination: legacyJob.destination, + variant: { + switch legacyJob.message { + case is ExpirationTimerUpdate: return .infoDisappearingMessagesUpdate + default: return nil + } + }(), + useFallback: true + ) + + return legacyInteractionIdentifierToIdFallbackMap[fallbackIdentifier] + }() let job: Job? = try Job( failureCount: legacyJob.failureCount, variant: .messageSend, behaviour: .runOnce, nextRunTimestamp: 0, - threadId: legacyThreadIdToIdMap[legacyJob.message.threadID ?? ""], + threadId: threadId, details: MessageSendJob.Details( - // Note: There are some cases where there isn't actually a link between the 'MessageSendJob' and - // it's associated interaction (ie. any ControlMessage), in these cases the 'interactionId' value - // will be nil - interactionId: legacyInteractionIdentifierToIdMap[legacyIdentifier], + // Note: There are some cases where there isn't a link between a + // 'MessageSendJob' and an interaction (eg. ConfigurationMessage), + // in these cases the 'interactionId' value will be nil + interactionId: interactionId, destination: legacyJob.destination, message: legacyJob.message ) @@ -893,15 +915,10 @@ enum _003_YDBToGRDBMigration: Migration { variant: .attachmentUpload, behaviour: .runOnce, nextRunTimestamp: 0, - details: String( - data: try JSONEncoder().encode( - AttachmentUploadJob.Details( - threadId: legacyJob.threadID, - attachmentId: legacyJob.attachmentID, - messageSendJobId: sendJobId - ) - ), - encoding: .utf8 + details: AttachmentUploadJob.Details( + threadId: legacyJob.threadID, + attachmentId: legacyJob.attachmentID, + messageSendJobId: sendJobId ) )?.inserted(db) } @@ -915,20 +932,20 @@ enum _003_YDBToGRDBMigration: Migration { SNLog("[Migration Error] attachmentDownload job unable to find interaction") throw GRDBStorageError.migrationFailed } - + guard let attachmentId: String = legacyAttachmentToIdMap[legacyJob.attachmentID] else { + SNLog("[Migration Error] attachmentDownload job unable to find attachment") + throw GRDBStorageError.migrationFailed + } + _ = try Job( failureCount: legacyJob.failureCount, variant: .attachmentDownload, behaviour: .runOnce, nextRunTimestamp: 0, - details: String( - data: try JSONEncoder().encode( - AttachmentDownloadJob.Details( - threadId: legacyJob.threadID, - attachmentId: legacyJob.attachmentID - ) - ), - encoding: .utf8 + threadId: legacyThreadIdToIdMap[legacyJob.threadID], + interactionId: interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: attachmentId ) )?.inserted(db) } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 43f79190b..b6ec24101 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -21,6 +21,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec case creationTimestamp case sourceFilename case downloadUrl + case localRelativeFilePath case width case height case encryptionKey @@ -81,6 +82,11 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec /// **Note:** The url is a fully constructed url but the clients just extract the id from the end of the url to perform the actual download public let downloadUrl: String? + /// The file path for the attachment relative to the attachments folder + /// + /// **Note:** We store this path so that file path generation changes don’t break existing attachments + public let localRelativeFilePath: String? + /// The width of the attachment, this will be `null` for non-visual attachment types public let width: UInt? @@ -107,6 +113,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec creationTimestamp: TimeInterval? = nil, sourceFilename: String? = nil, downloadUrl: String? = nil, + localRelativeFilePath: String? = nil, width: UInt? = nil, height: UInt? = nil, encryptionKey: Data? = nil, @@ -121,6 +128,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec self.creationTimestamp = creationTimestamp self.sourceFilename = sourceFilename self.downloadUrl = downloadUrl + self.localRelativeFilePath = localRelativeFilePath self.width = width self.height = height self.encryptionKey = encryptionKey @@ -153,6 +161,7 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec self.creationTimestamp = nil self.sourceFilename = nil self.downloadUrl = nil + self.localRelativeFilePath = nil self.width = imageSize.map { UInt(floor($0.width)) } self.height = imageSize.map { UInt(floor($0.height)) } self.encryptionKey = nil @@ -164,16 +173,41 @@ public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRec // MARK: - CustomStringConvertible extension Attachment: CustomStringConvertible { - public var description: String { + public static func description(for variant: Variant, contentType: String, sourceFilename: String?) -> String { if MIMETypeUtil.isAudio(contentType) { // a missing filename is the legacy way to determine if an audio attachment is // a voice note vs. other arbitrary audio attachments. - if variant == .voiceMessage || self.sourceFilename == nil || (self.sourceFilename?.count ?? 0) == 0 { + if variant == .voiceMessage || sourceFilename == nil || (sourceFilename?.count ?? 0) == 0 { return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())" } } - return "\("ATTACHMENT".localized()) \(emojiForMimeType)" + return "\("ATTACHMENT".localized()) \(emoji(for: contentType))" + } + + public static func emoji(for contentType: String) -> String { + if MIMETypeUtil.isImage(contentType) { + return "📷" + } + else if MIMETypeUtil.isVideo(contentType) { + return "🎥" + } + else if MIMETypeUtil.isAudio(contentType) { + return "🎧" + } + else if MIMETypeUtil.isAnimated(contentType) { + return "🎡" + } + + return "📎" + } + + public var description: String { + return Attachment.description( + for: variant, + contentType: contentType, + sourceFilename: sourceFilename + ) } } @@ -183,7 +217,9 @@ public extension Attachment { func with( serverId: String? = nil, state: State? = nil, + creationTimestamp: TimeInterval? = nil, downloadUrl: String? = nil, + localRelativeFilePath: String? = nil, encryptionKey: Data? = nil, digest: Data? = nil ) -> Attachment { @@ -193,9 +229,10 @@ public extension Attachment { state: (state ?? self.state), contentType: contentType, byteCount: byteCount, - creationTimestamp: creationTimestamp, + creationTimestamp: (creationTimestamp ?? self.creationTimestamp), sourceFilename: sourceFilename, downloadUrl: (downloadUrl ?? self.downloadUrl), + localRelativeFilePath: (localRelativeFilePath ?? self.localRelativeFilePath), width: width, height: height, encryptionKey: (encryptionKey ?? self.encryptionKey), @@ -236,6 +273,7 @@ public extension Attachment { self.creationTimestamp = nil self.sourceFilename = proto.fileName self.downloadUrl = proto.url + self.localRelativeFilePath = nil self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil) self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil) self.encryptionKey = proto.key @@ -343,7 +381,7 @@ public extension Attachment { OWSFileSystem.appSharedDataDirectoryPath().appending("/Attachments") }() - private static var attachmentsFolder: String = { + internal static var attachmentsFolder: String = { let attachmentsFolder: String = sharedDataAttachmentsDirPath OWSFileSystem.ensureDirectoryExists(attachmentsFolder) @@ -357,22 +395,13 @@ public extension Attachment { return attachmentsFolder }() - private static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? { - let maybeFilePath: String? = MIMETypeUtil.filePath( - forAttachment: id, // TODO: Can we avoid this??? + internal static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? { + return MIMETypeUtil.filePath( + forAttachment: id, ofMIMEType: mimeType, sourceFilename: sourceFilename, inFolder: Attachment.attachmentsFolder ) - - guard let filePath: String = maybeFilePath else { return nil } - guard filePath.hasPrefix(Attachment.attachmentsFolder) else { return nil } - - let localRelativeFilePath: String = filePath.substring(from: Attachment.attachmentsFolder.count) - - guard !localRelativeFilePath.isEmpty else { return nil } - - return localRelativeFilePath } static func imageSize(contentType: String, originalFilePath: String) -> CGSize? { @@ -410,10 +439,6 @@ extension Attachment { ) } - var localRelativeFilePath: String? { - return originalFilePath?.substring(from: Attachment.attachmentsFolder.count) - } - var thumbnailsDirPath: String { // Thumbnails are written to the caches directory, so that iOS can // remove them if necessary @@ -435,23 +460,6 @@ extension Attachment { return UIImage(contentsOfFile: originalFilePath) } - var emojiForMimeType: String { - if MIMETypeUtil.isImage(contentType) { - return "📷" - } - else if MIMETypeUtil.isVideo(contentType) { - return "🎥" - } - else if MIMETypeUtil.isAudio(contentType) { - return "🎧" - } - else if MIMETypeUtil.isAnimated(contentType) { - return "🎡" - } - - return "📎" - } - var isImage: Bool { MIMETypeUtil.isImage(contentType) } var isVideo: Bool { MIMETypeUtil.isVideo(contentType) } var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) } @@ -530,4 +538,12 @@ extension Attachment { public func cloneAsThumbnail() -> Attachment { fatalError("TODO: Add this back") } + + public func write(data: Data) throws -> Bool { + guard let originalFilePath: String = originalFilePath else { return false } + + try data.write(to: URL(fileURLWithPath: originalFilePath)) + + return true + } } diff --git a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift index 85eeb10da..509fa0c9e 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift @@ -4,22 +4,24 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroupKeyPair" } internal static let closedGroupForeignKey = ForeignKey( - [Columns.publicKey], + [Columns.threadId], to: [ClosedGroup.Columns.threadId] ) private static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId case publicKey case secretKey case receivedTimestamp } - public let publicKey: String + public let threadId: String + public let publicKey: Data public let secretKey: Data public let receivedTimestamp: TimeInterval @@ -32,10 +34,12 @@ public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, T // MARK: - Initialization public init( - publicKey: String, + threadId: String, + publicKey: Data, secretKey: Data, receivedTimestamp: TimeInterval ) { + self.threadId = threadId self.publicKey = publicKey self.secretKey = secretKey self.receivedTimestamp = receivedTimestamp @@ -45,9 +49,9 @@ public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, T // MARK: - GRDB Interactions public extension ClosedGroupKeyPair { - static func fetchLatestKeyPair(_ db: Database, publicKey: String) throws -> ClosedGroupKeyPair? { + static func fetchLatestKeyPair(_ db: Database, threadId: String) throws -> ClosedGroupKeyPair? { return try ClosedGroupKeyPair - .filter(Columns.publicKey == publicKey) + .filter(Columns.threadId == threadId) .order(Columns.receivedTimestamp.desc) .fetchOne(db) } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 9b9d85b6f..deb9d7c72 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -309,9 +309,6 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu default: break } - - } - } public func delete(_ db: Database) throws -> Bool { @@ -394,7 +391,7 @@ public extension Interaction { func scheduleJobs(interactionIds: [Int64]) { // Add the 'DisappearingMessagesJob' if needed - this will update any expiring // messages `expiresStartedAtMs` values - JobRunner.add( + JobRunner.upsert( db, job: Job( variant: .disappearingMessages, @@ -541,6 +538,13 @@ public extension Interaction { case .standardIncomingDeleted: return "" case .standardIncoming, .standardOutgoing: + struct AttachmentDescriptionInfo: Decodable, FetchableRecord { + let id: String + let variant: Attachment.Variant + let contentType: String + let sourceFilename: String? + } + var bodyDescription: String? if let body: String = self.body, !body.isEmpty { @@ -548,14 +552,35 @@ public extension Interaction { } if bodyDescription == nil { - let maybeTextAttachment: Attachment? = try? attachments - .filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage) - .fetchOne(db) + struct AttachmentBodyInfo: Decodable, FetchableRecord { + let id: String + let variant: Attachment.Variant + let contentType: String + let sourceFilename: String? + } + + let maybeTextInfo: AttachmentDescriptionInfo? = try? AttachmentDescriptionInfo + .fetchOne( + db, + attachments + .select( + Attachment.Columns.id, + Attachment.Columns.state, + Attachment.Columns.variant, + Attachment.Columns.contentType, + Attachment.Columns.sourceFilename + ) + .filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage) + .filter(Attachment.Columns.state == Attachment.State.downloaded) + ) if - let attachment: Attachment = maybeTextAttachment, - attachment.state == .downloaded, - let filePath: String = attachment.originalFilePath, + let textInfo: AttachmentDescriptionInfo = maybeTextInfo, + let filePath: String = Attachment.originalFilePath( + id: textInfo.id, + mimeType: textInfo.contentType, + sourceFilename: textInfo.sourceFilename + ), let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), let dataString: String = String(data: data, encoding: .utf8) { @@ -563,14 +588,25 @@ public extension Interaction { } } - var attachmentDescription: String? - let maybeMediaAttachment: Attachment? = try? attachments - .filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage) - .fetchOne(db) - - if let attachment: Attachment = maybeMediaAttachment { - attachmentDescription = attachment.description - } + let attachmentDescription: String? = try? AttachmentDescriptionInfo + .fetchOne( + db, + attachments + .select( + Attachment.Columns.id, + Attachment.Columns.variant, + Attachment.Columns.contentType, + Attachment.Columns.sourceFilename + ) + .filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage) + ) + .map { info -> String in + Attachment.description( + for: info.variant, + contentType: info.contentType, + sourceFilename: info.sourceFilename + ) + } if let attachmentDescription: String = attachmentDescription, @@ -627,9 +663,12 @@ public extension Interaction { } func state(_ db: Database) throws -> RecipientState.State { - let states: [RecipientState.State] = try recipientStates - .fetchAll(db) - .map { $0.state } + let states: [RecipientState.State] = try RecipientState.State + .fetchAll( + db, + recipientStates + .select(RecipientState.Columns.state) + ) var hasFailed: Bool = false for state in states { diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index ecfa280fb..b61007329 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -36,9 +36,11 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord // If we have an Attachment then check if this is the only type that is referencing it // and delete the Attachment if so let quoteUses: Int? = try? Quote + .select(Quote.Columns.attachmentId) .filter(Quote.Columns.attachmentId == attachmentId) .fetchCount(db) let linkPreviewUses: Int? = try? LinkPreview + .select(LinkPreview.Columns.attachmentId) .filter(LinkPreview.Columns.attachmentId == attachmentId) .fetchCount(db) diff --git a/SessionMessagingKit/Database/Models/Job.swift b/SessionMessagingKit/Database/Models/Job.swift index bdc87f812..a6159af22 100644 --- a/SessionMessagingKit/Database/Models/Job.swift +++ b/SessionMessagingKit/Database/Models/Job.swift @@ -9,9 +9,14 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer public static var databaseTableName: String { "job" } internal static let threadForeignKey = ForeignKey( [Columns.threadId], - to: [Interaction.Columns.threadId] + to: [SessionThread.Columns.id] + ) + internal static let interactionForeignKey = ForeignKey( + [Columns.interactionId], + to: [Interaction.Columns.id] ) internal static let thread = hasOne(SessionThread.self, using: Job.threadForeignKey) + internal static let interaction = hasOne(Interaction.self, using: Job.interactionForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -21,6 +26,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer case behaviour case nextRunTimestamp case threadId + case interactionId case details } @@ -101,11 +107,18 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer /// Seconds since epoch to indicate the next datetime that this job should run public let nextRunTimestamp: TimeInterval - /// The id of the thread this job is associated with + /// The id of the thread this job is associated with, if the associated thread is deleted this job will + /// also be deleted /// /// **Note:** This will only be populated for Jobs associated to threads public let threadId: String? + /// The id of the interaction this job is associated with, if the associated interaction is deleted this + /// job will also be deleted + /// + /// **Note:** This will only be populated for Jobs associated to interactions + public let interactionId: Int64? + /// JSON encoded data required for the job public let details: Data? @@ -115,6 +128,10 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer request(for: Job.thread) } + public var interaction: QueryInterfaceRequest { + request(for: Job.interaction) + } + // MARK: - Initialization fileprivate init( @@ -124,6 +141,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer behaviour: Behaviour, nextRunTimestamp: TimeInterval, threadId: String?, + interactionId: Int64?, details: Data? ) { self.id = id @@ -132,6 +150,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer self.behaviour = behaviour self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId + self.interactionId = interactionId self.details = details } @@ -140,13 +159,15 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer variant: Variant, behaviour: Behaviour = .runOnce, nextRunTimestamp: TimeInterval = 0, - threadId: String? = nil + threadId: String? = nil, + interactionId: Int64? = nil ) { self.failureCount = failureCount self.variant = variant self.behaviour = behaviour self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId + self.interactionId = interactionId self.details = nil } @@ -156,24 +177,20 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer behaviour: Behaviour = .runOnce, nextRunTimestamp: TimeInterval = 0, threadId: String? = nil, - details: T? = nil + interactionId: Int64? = nil, + details: T? ) { - let detailsData: Data? - - if let details: T = details { - guard let encodedDetails: Data = try? JSONEncoder().encode(details) else { return nil } - - detailsData = encodedDetails - } - else { - detailsData = nil - } + guard + let details: T = details, + let detailsData: Data = try? JSONEncoder().encode(details) + else { return nil } self.failureCount = failureCount self.variant = variant self.behaviour = behaviour self.nextRunTimestamp = nextRunTimestamp self.threadId = threadId + self.interactionId = interactionId self.details = detailsData } @@ -198,6 +215,7 @@ public extension Job { behaviour: behaviour, nextRunTimestamp: (nextRunTimestamp ?? self.nextRunTimestamp), threadId: threadId, + interactionId: interactionId, details: details ) } @@ -212,6 +230,7 @@ public extension Job { behaviour: behaviour, nextRunTimestamp: nextRunTimestamp, threadId: threadId, + interactionId: interactionId, details: detailsData ) } diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 8d478415d..acaa31c76 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -93,7 +93,6 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco userCount: Int, infoUpdates: Int ) { - // Always force the server to lowercase self.threadId = OpenGroup.idFor(room: room, server: server) self.server = server.lowercased() self.room = room diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 7b8411041..068f41e29 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -40,14 +40,38 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case mentionsOnly // Only applicable to group threads } + /// Unique identifier for a thread (formerly known as uniqueId) + /// + /// This value will depend on the variant: + /// **contact:** The contact id + /// **closedGroup:** The closed group public key + /// **openGroup:** The `\(server.lowercased()).\(room)` value public let id: String + + /// Enum indicating what type of thread this is public let variant: Variant + + /// A timestamp indicating when this thread was created public let creationDateTimestamp: TimeInterval + + /// A flag indicating whether the thread should be visible public let shouldBeVisible: Bool + + /// A flag indicating whether the thread is pinned public let isPinned: Bool + + /// The value the user started entering into the input field before they left the conversation screen public let messageDraft: String? + + /// The notification mode this thread is set to public let notificationMode: NotificationMode + + /// The sound which should be used when receiving a notification for this thread + /// + /// **Note:** If unset this will use the `Preferences.Sound.defaultNotificationSound` public let notificationSound: Preferences.Sound? + + /// Timestamp (seconds since epoch) for when this thread should stop being muted public let mutedUntilTimestamp: TimeInterval? // MARK: - Relationships @@ -142,14 +166,14 @@ public extension SessionThread { case .contact: return Profile.displayName(db, id: id) case .closedGroup: - guard let name: String = try? closedGroup.fetchOne(db)?.name, !name.isEmpty else { + guard let name: String = try? String.fetchOne(db, closedGroup.select(ClosedGroup.Columns.name)), !name.isEmpty else { return "Group" } return name case .openGroup: - guard let name: String = try? openGroup.fetchOne(db)?.name, !name.isEmpty else { + guard let name: String = try? String.fetchOne(db, openGroup.select(OpenGroup.Columns.name)), !name.isEmpty else { return "Group" } diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift deleted file mode 100644 index 7da24d920..000000000 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit -import SessionSnodeKit -import SignalCoreKit - -public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - public let attachmentID: String - public let tsMessageID: String - public let threadID: String - public var delegate: JobDelegate? - public var id: String? - public var failureCount: UInt = 0 - public var isDeferred = false - - public enum Error : LocalizedError { - case noAttachment - case invalidURL - - public var errorDescription: String? { - switch self { - case .noAttachment: return "No such attachment." - case .invalidURL: return "Invalid file URL." - } - } - } - - // MARK: Settings - public class var collection: String { return "AttachmentDownloadJobCollection" } - public static let maxFailureCount: UInt = 20 - - // MARK: Initialization - public init(attachmentID: String, tsMessageID: String, threadID: String) { - self.attachmentID = attachmentID - self.tsMessageID = tsMessageID - self.threadID = threadID - } - - // MARK: Coding - public init?(coder: NSCoder) { - guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, - let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?, - let threadID = coder.decodeObject(forKey: "threadID") as! String?, - let id = coder.decodeObject(forKey: "id") as! String? else { return nil } - self.attachmentID = attachmentID - self.tsMessageID = tsMessageID - self.threadID = threadID - self.id = id - self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 - self.isDeferred = coder.decodeBool(forKey: "isDeferred") - } - - public func encode(with coder: NSCoder) { - coder.encode(attachmentID, forKey: "attachmentID") - coder.encode(tsMessageID, forKey: "tsIncomingMessageID") - coder.encode(threadID, forKey: "threadID") - coder.encode(id, forKey: "id") - coder.encode(failureCount, forKey: "failureCount") - coder.encode(isDeferred, forKey: "isDeferred") - } - - // MARK: Running - public func execute() { - if let id = id { - JobQueue.currentlyExecutingJobs.insert(id) - } - guard !isDeferred else { return } - if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream { - // FIXME: It's not clear * how * this happens, but apparently we can get to this point - // from time to time with an already downloaded attachment. - return handleSuccess() - } - guard let pointer = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentPointer else { - return handleFailure(error: Error.noAttachment) - } - let storage = SNMessagingKitConfiguration.shared.storage - storage.write(with: { transaction in - storage.setAttachmentState(to: .downloading, for: pointer, associatedWith: self.tsMessageID, using: transaction) - }, completion: { }) - let temporaryFilePath = URL(fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString) - let handleFailure: (Swift.Error) -> Void = { error in // Intentionally capture self - OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) - if let error = error as? Error, case .noAttachment = error { - storage.write(with: { transaction in - storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) - }, completion: { }) - self.handlePermanentFailure(error: error) - } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, - statusCode == 400 { - // Otherwise, the attachment will show a state of downloading forever, - // and the message won't be able to be marked as read. - storage.write(with: { transaction in - storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) - }, completion: { }) - // This usually indicates a file that has expired on the server, so there's no need to retry. - self.handlePermanentFailure(error: error) - } else { - self.handleFailure(error: error) - } - } - if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let v2OpenGroup = storage.getV2OpenGroup(for: tsMessage.uniqueThreadId) { - guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { - return handleFailure(Error.invalidURL) - } - OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in - self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) - }.catch(on: DispatchQueue.global()) { error in - handleFailure(error) - } - } else { - guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { - return handleFailure(Error.invalidURL) - } - let useOldServer = pointer.downloadURL.contains(FileServerAPIV2.oldServer) - FileServerAPIV2.download(file, useOldServer: useOldServer).done(on: DispatchQueue.global(qos: .userInitiated)) { data in - self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) - }.catch(on: DispatchQueue.global()) { error in - handleFailure(error) - } - } - } - - private func handleDownloadedAttachment(data: Data, temporaryFilePath: URL, pointer: TSAttachmentPointer, failureHandler: (Swift.Error) -> Void) { - let storage = SNMessagingKitConfiguration.shared.storage - do { - try data.write(to: temporaryFilePath, options: .atomic) - } catch { - return failureHandler(error) - } - let plaintext: Data - if let key = pointer.encryptionKey, let digest = pointer.digest, key.count > 0 && digest.count > 0 { - do { - plaintext = try Cryptography.decryptAttachment(data, withKey: key, digest: digest, unpaddedSize: pointer.byteCount) - } catch { - return failureHandler(error) - } - } else { - plaintext = data // Open group attachments are unencrypted - } - let stream = TSAttachmentStream(pointer: pointer) - do { - try stream.write(plaintext) - } catch { - return failureHandler(error) - } - OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) - storage.write(with: { transaction in - storage.persist(stream, associatedWith: self.tsMessageID, using: transaction) - }, completion: { - self.handleSuccess() - }) - } - - private func handleSuccess() { - delegate?.handleJobSucceeded(self) - } - - private func handlePermanentFailure(error: Swift.Error) { - delegate?.handleJobFailedPermanently(self, with: error) - } - - private func handleFailure(error: Swift.Error) { - delegate?.handleJobFailed(self, with: error) - } -} diff --git a/SessionMessagingKit/Jobs/JobRunner.swift b/SessionMessagingKit/Jobs/JobRunner.swift index 9049b4086..0f9d154b3 100644 --- a/SessionMessagingKit/Jobs/JobRunner.swift +++ b/SessionMessagingKit/Jobs/JobRunner.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public protocol JobExecutor { static var maxFailureCount: UInt { get } static var requiresThreadId: Bool { get } + static var requiresInteractionId: Bool { get } /// This method contains the logic needed to complete a job /// @@ -35,10 +36,11 @@ public final class JobRunner { private class Trigger { private var timer: Timer? - static func create(timestamp: TimeInterval) -> Trigger { + static func create(timestamp: TimeInterval) -> Trigger? { + // Setup the trigger (wait at least 1 second before triggering) let trigger: Trigger = Trigger() trigger.timer = Timer.scheduledTimer( - timeInterval: timestamp, + timeInterval: max(1, (timestamp - Date().timeIntervalSince1970)), target: self, selector: #selector(start), userInfo: nil, @@ -57,7 +59,6 @@ public final class JobRunner { // TODO: Could this be a bottleneck? (single serial queue to process all these jobs? Group by thread?) // TODO: Multi-thread support - private static let minRetryInterval: TimeInterval = 1 private static let queueKey: DispatchSpecificKey = DispatchSpecificKey() private static let queueContext: String = "JobRunner" private static let internalQueue: DispatchQueue = { @@ -82,6 +83,11 @@ public final class JobRunner { // 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 { @@ -89,10 +95,12 @@ public final class JobRunner { return } - switch (canStartJob, updatedJob.behaviour) { - case (false, _), (_, .runOnceNextLaunch): return - default: break - } + // Check if the job should be added to the queue + guard + canStartJob, + updatedJob.behaviour != .runOnceNextLaunch, + updatedJob.nextRunTimestamp <= Date().timeIntervalSince1970 + else { return } jobQueue.mutate { $0.append(updatedJob) } @@ -104,6 +112,11 @@ public final class JobRunner { } } + /// 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 let jobId: Int64 = job.id else { @@ -113,6 +126,9 @@ public final class JobRunner { // Lock the queue 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 + // the 'jobsCurrentlyRunning' set var didUpdateExistingJob: Bool = false jobQueue.mutate { queue in @@ -230,15 +246,28 @@ public final class JobRunner { .fetchAll(db) } + // Determine the number of jobs to run + var jobCount: Int = 0 + + jobQueue.mutate { queue in + // Add the jobs to the queue + if let jobsToRun: [Job] = maybeJobsToRun { + queue.append(contentsOf: jobsToRun) + } + + jobCount = queue.count + } + // If there are no pending jobs then schedule the JobRunner to start again // when the next scheduled job should start - guard let jobsToRun: [Job] = maybeJobsToRun else { + guard jobCount > 0 else { + isRunning.mutate { $0 = false } scheduleNextSoonestJob() return } - // Add the jobs to the queue and run the first job in the queue - jobQueue.mutate { $0.append(contentsOf: jobsToRun) } + // Run the first job in the queue + SNLog("[JobRunner] Starting with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") runNextJob() } @@ -250,9 +279,9 @@ public final class JobRunner { } return } - guard let nextJob: Job = jobQueue.mutate({ $0.popFirst() }) else { - scheduleNextSoonestJob() + guard let (nextJob, numJobsRemaining): (Job, Int) = jobQueue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { isRunning.mutate { $0 = false } + scheduleNextSoonestJob() return } guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else { @@ -265,13 +294,17 @@ public final class JobRunner { handleJobFailed(nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true) return } + guard !jobExecutor.requiresInteractionId || nextJob.interactionId != nil else { + SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing required interactionId") + handleJobFailed(nextJob, error: JobRunnerError.requiredInteractionIdMissing, permanentFailure: true) + return + } // Update the state to indicate it's running // // Note: We need to store 'numJobsRemaining' in it's own variable because // the 'SNLog' seems to dispatch to it's own queue which ends up getting // blocked by the JobRunner's queue becuase 'jobQueue' is Atomic - let numJobsRemaining: Int = jobQueue.wrappedValue.count nextTrigger.mutate { $0 = nil } isRunning.mutate { $0 = true } jobsCurrentlyRunning.mutate { $0 = $0.inserting(nextJob.id) } @@ -286,19 +319,38 @@ public final class JobRunner { } private static func scheduleNextSoonestJob() { - let maybeJob: Job? = GRDBStorage.shared.read { db in - try Job - .filter( - [ - Job.Behaviour.runOnce, - Job.Behaviour.recurring - ].contains(Job.Columns.behaviour) - ) - .order(Job.Columns.nextRunTimestamp) - .fetchOne(db) + let nextJobTimestamp: TimeInterval? = GRDBStorage.shared + .read { db in + try TimeInterval + .fetchOne( + db, + Job + .select(Job.Columns.nextRunTimestamp) + .filter( + [ + Job.Behaviour.runOnce, + Job.Behaviour.recurring + ].contains(Job.Columns.behaviour) + ) + .order(Job.Columns.nextRunTimestamp) + ) + } + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp else { return } + + // If the next job isn't scheduled in the future then just restart the JobRunner immediately + let secondsUntilNextJob: TimeInterval = (nextJobTimestamp - Date().timeIntervalSince1970) + guard secondsUntilNextJob > 0 else { + SNLog("[JobRunner] Restarting immediately for job scheduled \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")) ago") + + internalQueue.async { + JobRunner.start() + } + return } - let targetTimestamp: TimeInterval = (maybeJob?.nextRunTimestamp ?? (Date().timeIntervalSince1970 + minRetryInterval)) - nextTrigger.mutate { $0 = Trigger.create(timestamp: targetTimestamp) } + + // Setup a trigger + SNLog("[JobRunner] Stopping until next job in \(Int(ceil(abs(secondsUntilNextJob))))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s"))") + nextTrigger.mutate { $0 = Trigger.create(timestamp: nextJobTimestamp) } } // MARK: - Handling Results @@ -316,13 +368,14 @@ public final class JobRunner { 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) case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970: - // For `recurring` jobs we want the job to run again but want at least 1 second to pass GRDBStorage.shared.write { db in - var updatedJob: Job = job.with( - nextRunTimestamp: (Date().timeIntervalSince1970 + 1) - ) - try updatedJob.save(db) + _ = try job + .with(nextRunTimestamp: (Date().timeIntervalSince1970 + 1)) + .saved(db) } default: break diff --git a/SessionMessagingKit/Jobs/JobRunnerError.swift b/SessionMessagingKit/Jobs/JobRunnerError.swift index 0cedc9968..8a88fa80e 100644 --- a/SessionMessagingKit/Jobs/JobRunnerError.swift +++ b/SessionMessagingKit/Jobs/JobRunnerError.swift @@ -7,6 +7,7 @@ public enum JobRunnerError: Error { case executorMissing case requiredThreadIdMissing + case requiredInteractionIdMissing case missingRequiredDetails } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift new file mode 100644 index 000000000..990b90273 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -0,0 +1,309 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionUtilitiesKit +import SessionSnodeKit +import SignalCoreKit + +public enum AttachmentDownloadJob: JobExecutor { + public static var maxFailureCount: UInt = 10 + public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = true + + public static func run( + _ job: Job, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + guard + let threadId: String = job.threadId, + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), + var attachment: Attachment = GRDBStorage.shared + .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + guard attachment.state != .downloaded else { + // FIXME: It's not clear * how * this happens, but apparently we can get to this point from time to time with an already downloaded attachment. + success(job, false) + return + } + + // Update to the 'downloading' state + attachment = GRDBStorage.shared + .write { db in + try attachment + .with(state: .downloading) + .saved(db) + } + .defaulting(to: attachment) + + let temporaryFilePath: URL = URL( + fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString + ) + let downloadPromise: Promise = { + guard + let downloadUrl: String = attachment.downloadUrl, + let fileAsString: String = downloadUrl.split(separator: "/").last.map({ String($0) }), + let file: UInt64 = UInt64(fileAsString) + else { + return Promise(error: AttachmentDownloadError.invalidUrl) + } + + if let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) { + return OpenGroupAPIV2.download(file, from: openGroup.room, on: openGroup.server) + } + + return FileServerAPIV2.download(file, useOldServer: downloadUrl.contains(FileServerAPIV2.oldServer)) + }() + + downloadPromise + .then { data -> Promise in + try data.write(to: temporaryFilePath, options: .atomic) + + let plaintext: Data = try { + guard + let key: Data = attachment.encryptionKey, + let digest: Data = attachment.digest, + key.count > 0, + digest.count > 0 + else { return data } // Open group attachments are unencrypted + + return try Cryptography.decryptAttachment( + data, + withKey: key, + digest: digest, + unpaddedSize: UInt32(attachment.byteCount) + ) + }() + + guard try attachment.write(data: plaintext) else { + throw AttachmentDownloadError.failedToSaveFile + } + + return Promise.value(()) + } + .done { + // Remove the temporary file + OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) + + // Update the attachment state + GRDBStorage.shared.write { db in + try attachment + .with( + state: .downloaded, + creationTimestamp: Date().timeIntervalSince1970, + localRelativeFilePath: attachment.originalFilePath? + .substring(from: Attachment.attachmentsFolder.count) + ) + .save(db) + } + + success(job, false) + } + .catch { error in + OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) + + switch error { + case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400: + // Otherwise, the attachment will show a state of downloading forever, + // and the message won't be able to be marked as read + GRDBStorage.shared.write { db in + try attachment + .with(state: .failed) + .save(db) + } + + // This usually indicates a file that has expired on the server, so there's no need to retry + failure(job, error, true) + + default: + failure(job, error, false) + } + } + } +} + +// MARK: - AttachmentDownloadJob.Details + +extension AttachmentDownloadJob { + public struct Details: Codable { + public let attachmentId: String + } + + public enum AttachmentDownloadError: LocalizedError { + case failedToSaveFile + case invalidUrl + + public var errorDescription: String? { + switch self { + case .failedToSaveFile: return "Failed to save file" + case .invalidUrl: return "Invalid file URL" + } + } + } +} +// TODO: MessageInvalidator.invalidate(tsMessage, with: transaction) + +// public let attachmentID: String +// public let tsMessageID: String +// public let threadID: String +// public var delegate: JobDelegate? +// public var id: String? +// public var failureCount: UInt = 0 +// public var isDeferred = false +// +// public enum Error : LocalizedError { +// case noAttachment +// case invalidURL +// +// public var errorDescription: String? { +// switch self { +// case .noAttachment: return "No such attachment." +// case .invalidURL: return "Invalid file URL." +// } +// } +// } +// +// // MARK: Settings +// public class var collection: String { return "AttachmentDownloadJobCollection" } +// public static let maxFailureCount: UInt = 20 +// +// // MARK: Initialization +// public init(attachmentID: String, tsMessageID: String, threadID: String) { +// self.attachmentID = attachmentID +// self.tsMessageID = tsMessageID +// self.threadID = threadID +// } +// +// // MARK: Coding +// public init?(coder: NSCoder) { +// guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?, +// let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?, +// let threadID = coder.decodeObject(forKey: "threadID") as! String?, +// let id = coder.decodeObject(forKey: "id") as! String? else { return nil } +// self.attachmentID = attachmentID +// self.tsMessageID = tsMessageID +// self.threadID = threadID +// self.id = id +// self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0 +// self.isDeferred = coder.decodeBool(forKey: "isDeferred") +// } +// +// public func encode(with coder: NSCoder) { +// coder.encode(attachmentID, forKey: "attachmentID") +// coder.encode(tsMessageID, forKey: "tsIncomingMessageID") +// coder.encode(threadID, forKey: "threadID") +// coder.encode(id, forKey: "id") +// coder.encode(failureCount, forKey: "failureCount") +// coder.encode(isDeferred, forKey: "isDeferred") +// } +// +// // MARK: Running +// public func execute() { +// if let id = id { +// JobQueue.currentlyExecutingJobs.insert(id) +// } +// guard !isDeferred else { return } +// if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream { +// // FIXME: It's not clear * how * this happens, but apparently we can get to this point +// // from time to time with an already downloaded attachment. +// return handleSuccess() +// } +// guard let pointer = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentPointer else { +// return handleFailure(error: Error.noAttachment) +// } +// let storage = SNMessagingKitConfiguration.shared.storage +// storage.write(with: { transaction in +// storage.setAttachmentState(to: .downloading, for: pointer, associatedWith: self.tsMessageID, using: transaction) +// }, completion: { }) +// let temporaryFilePath = URL(fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString) +// let handleFailure: (Swift.Error) -> Void = { error in // Intentionally capture self +// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) +// if let error = error as? Error, case .noAttachment = error { +// storage.write(with: { transaction in +// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) +// }, completion: { }) +// self.handlePermanentFailure(error: error) +// } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error, +// statusCode == 400 { +// // Otherwise, the attachment will show a state of downloading forever, +// // and the message won't be able to be marked as read. +// storage.write(with: { transaction in +// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction) +// }, completion: { }) +// // This usually indicates a file that has expired on the server, so there's no need to retry. +// self.handlePermanentFailure(error: error) +// } else { +// self.handleFailure(error: error) +// } +// } +// if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let v2OpenGroup = storage.getV2OpenGroup(for: tsMessage.uniqueThreadId) { +// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { +// return handleFailure(Error.invalidURL) +// } +// OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in +// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) +// }.catch(on: DispatchQueue.global()) { error in +// handleFailure(error) +// } +// } else { +// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { +// return handleFailure(Error.invalidURL) +// } +// let useOldServer = pointer.downloadURL.contains(FileServerAPIV2.oldServer) +// FileServerAPIV2.download(file, useOldServer: useOldServer).done(on: DispatchQueue.global(qos: .userInitiated)) { data in +// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) +// }.catch(on: DispatchQueue.global()) { error in +// handleFailure(error) +// } +// } +// } +// +// private func handleDownloadedAttachment(data: Data, temporaryFilePath: URL, pointer: TSAttachmentPointer, failureHandler: (Swift.Error) -> Void) { +// let storage = SNMessagingKitConfiguration.shared.storage +// do { +// try data.write(to: temporaryFilePath, options: .atomic) +// } catch { +// return failureHandler(error) +// } +// let plaintext: Data +// if let key = pointer.encryptionKey, let digest = pointer.digest, key.count > 0 && digest.count > 0 { +// do { +// plaintext = try Cryptography.decryptAttachment(data, withKey: key, digest: digest, unpaddedSize: pointer.byteCount) +// } catch { +// return failureHandler(error) +// } +// } else { +// plaintext = data // Open group attachments are unencrypted +// } +// let stream = TSAttachmentStream(pointer: pointer) +// do { +// try stream.write(plaintext) +// } catch { +// return failureHandler(error) +// } +// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString) +// storage.write(with: { transaction in +// storage.persist(stream, associatedWith: self.tsMessageID, using: transaction) +// }, completion: { +// self.handleSuccess() +// }) +// } +// +// private func handleSuccess() { +// delegate?.handleJobSucceeded(self) +// } +// +// private func handlePermanentFailure(error: Swift.Error) { +// delegate?.handleJobFailedPermanently(self, with: error) +// } +// +// private func handleFailure(error: Swift.Error) { +// delegate?.handleJobFailed(self, with: error) +// } +//} diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index aa7b8569d..acc59cab1 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -7,6 +7,7 @@ import SessionUtilitiesKit public enum DisappearingMessagesJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, @@ -64,7 +65,7 @@ public extension DisappearingMessagesJob { .saved(db) } - @discardableResult static func updateNextRunIfNeeded(_ db: Database, interactionIds: [Int64], startedAtMs: Double) -> Bool { + @discardableResult static func updateNextRunIfNeeded(_ db: Database, interactionIds: [Int64], startedAtMs: Double) -> Job? { // Update the expiring messages expiresStartedAtMs value let changeCount: Int? = try? Interaction .filter(interactionIds.contains(Interaction.Columns.id)) @@ -72,17 +73,17 @@ public extension DisappearingMessagesJob { .updateAll(db, Interaction.Columns.expiresStartedAtMs.set(to: startedAtMs)) // If there were no changes then none of the provided `interactionIds` are expiring messages - guard (changeCount ?? 0) > 0 else { return false } + guard (changeCount ?? 0) > 0 else { return nil } - return (updateNextRunIfNeeded(db) != nil) + return updateNextRunIfNeeded(db) } - @discardableResult static func updateNextRunIfNeeded(_ db: Database, interaction: Interaction, startedAtMs: Double) -> Bool { - guard interaction.isExpiringMessage else { return false } + @discardableResult static func updateNextRunIfNeeded(_ db: Database, interaction: Interaction, startedAtMs: Double) -> Job? { + guard interaction.isExpiringMessage else { return nil } // Don't clobber if multiple actions simultaneously triggered expiration guard interaction.expiresStartedAtMs == nil || (interaction.expiresStartedAtMs ?? 0) > startedAtMs else { - return false + return nil } do { @@ -94,7 +95,7 @@ public extension DisappearingMessagesJob { } catch { SNLog("Failed to update the expiring messages timer on an interaction") - return false + return nil } } } diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift index ceb84c01b..2c10422da 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public enum FailedAttachmentDownloadsJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, diff --git a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift index 0017d680c..30104c176 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public enum FailedMessagesJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 759ffe2f1..59027e7e6 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -7,6 +7,7 @@ import SessionUtilitiesKit public enum MessageReceiveJob: JobExecutor { public static var maxFailureCount: UInt = 10 public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index af927afc1..2f6dc5cc3 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -9,6 +9,7 @@ import SessionSnodeKit public enum MessageSendJob: JobExecutor { public static var maxFailureCount: UInt = 10 public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = false // Some messages don't have interactions public static func run( _ job: Job, diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index 17475fdf1..95f00bf27 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public enum NotifyPushServerJob: JobExecutor { public static var maxFailureCount: UInt = 20 public static var requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false public static func run( _ job: Job, @@ -36,7 +37,7 @@ public enum NotifyPushServerJob: JobExecutor { "Content-Type": "application/json" ] - let promise = attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { + attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) { OnionRequestAPI .sendOnionRequest( request, @@ -49,11 +50,10 @@ public enum NotifyPushServerJob: JobExecutor { .done { _ in success(job, false) } - - promise.catch { error in + .`catch` { error in failure(job, error, false) } - promise.retainUntilComplete() + .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 36c31c595..9f6d8dfd1 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public enum SendReadReceiptsJob: JobExecutor { public static let maxFailureCount: UInt = 0 public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false private static let minRunFrequency: TimeInterval = 3 public static func run( diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index c4d6d61b3..207086f95 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -22,7 +22,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; #import #import #import -#import #import #import #import diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 32f5aa9fa..5791f5e85 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -9,6 +9,10 @@ import SessionUtilitiesKit extension MessageReceiver { + /// Extract the sender public key (used as the threadId in contact threads) + /// + /// **Note:** This is a slightly optimised version of the `decryptWithSessionProtocol` function which just skips + /// the validation (handled when the job actually runs) and doesn't throw internal static func extractSenderPublicKey(_ db: Database, from envelope: SNProtoEnvelope) -> String? { guard let ciphertext: Data = envelope.content, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index a8930b995..7746af472 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -594,8 +594,9 @@ extension MessageReceiver { db, job: Job( variant: .attachmentDownload, + threadId: thread.id, + interactionId: interactionId, details: AttachmentDownloadJob.Details( - threadId: thread.id, attachmentId: attachmentId ) ), @@ -818,7 +819,8 @@ extension MessageReceiver { Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction) // Store the key pair try ClosedGroupKeyPair( - publicKey: groupPublicKey, + threadId: groupPublicKey, + publicKey: Data(encryptionKeyPair.publicKey), secretKey: Data(encryptionKeyPair.secretKey), receivedTimestamp: Date().timeIntervalSince1970 ).insert(db) @@ -876,24 +878,18 @@ extension MessageReceiver { return SNLog("Couldn't parse closed group encryption key pair.") } - let keyPair: Box.KeyPair = Box.KeyPair( - publicKey: proto.publicKey.removing05PrefixIfNeeded().bytes, - secretKey: proto.privateKey.bytes - ) - - // Store it if needed - let keyPairs: [ClosedGroupKeyPair] = ((try? closedGroup.keyPairs.fetchAll(db)) ?? []) - let secretKeyData: Data = Data(keyPair.secretKey) - - guard !keyPairs.contains(where: { $0.secretKey == secretKeyData }) else { + do { + try ClosedGroupKeyPair( + threadId: groupPublicKey, + publicKey: proto.publicKey.removing05PrefixIfNeeded(), + secretKey: proto.privateKey, + receivedTimestamp: Date().timeIntervalSince1970 + ).insert(db) + } + catch { return SNLog("Ignoring duplicate closed group encryption key pair.") } - try ClosedGroupKeyPair( - publicKey: keyPair.publicKey.toHexString(), // Should match 'groupPublicKey' - secretKey: secretKeyData, - receivedTimestamp: Date().timeIntervalSince1970 - ).insert(db) SNLog("Received a new closed group encryption key pair.") } @@ -1019,8 +1015,8 @@ extension MessageReceiver { // • Stop polling for the group // • Remove the key pairs associated with the group // • Notify the PN server - let userPublicKey = getUserHexEncodedPublicKey() - let wasCurrentUserRemoved = !members.contains(userPublicKey) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let wasCurrentUserRemoved: Bool = !members.contains(userPublicKey) if wasCurrentUserRemoved { ClosedGroupPoller.shared.stopPolling(for: id) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 719141e75..2fb678ac1 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -66,7 +66,7 @@ public enum MessageReceiver { return try decryptWithSessionProtocol( ciphertext: ciphertext, using: Box.KeyPair( - publicKey: Data(hex: keyPair.publicKey).bytes, + publicKey: keyPair.publicKey.bytes, secretKey: keyPair.secretKey.bytes ) ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift index da870d85c..c5c40756f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift @@ -8,7 +8,7 @@ import PromiseKit import SessionUtilitiesKit extension MessageSender { - public static var distributingClosedGroupEncryptionKeyPairs: [String: [Box.KeyPair]] = [:] + public static var distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:]) public static func createClosedGroup(_ db: Database, name: String, members: Set) throws -> Promise { let userPublicKey: String = getUserHexEncodedPublicKey() @@ -18,10 +18,6 @@ extension MessageSender { let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the "05" prefix // Generate the key pair that'll be used for encryption and decryption let encryptionKeyPair = Curve25519.generateKeyPair() - let keyPair: Box.KeyPair = Box.KeyPair( - publicKey: encryptionKeyPair.publicKey.bytes, - secretKey: encryptionKeyPair.privateKey.bytes - ) // Create the group members.insert(userPublicKey) // Ensure the current user is included in the member list @@ -72,7 +68,10 @@ extension MessageSender { kind: .new( publicKey: Data(hex: groupPublicKey), name: name, - encryptionKeyPair: keyPair, + encryptionKeyPair: Box.KeyPair( + publicKey: encryptionKeyPair.publicKey.bytes, + secretKey: encryptionKeyPair.privateKey.bytes + ), members: membersAsData, admins: adminsAsData, expirationTimer: 0 @@ -86,8 +85,9 @@ extension MessageSender { // Store the key pair try ClosedGroupKeyPair( - publicKey: keyPair.publicKey.toHexString(), - secretKey: Data(keyPair.secretKey), + threadId: groupPublicKey, + publicKey: encryptionKeyPair.publicKey, + secretKey: encryptionKeyPair.privateKey, receivedTimestamp: Date().timeIntervalSince1970 ).insert(db) @@ -134,20 +134,25 @@ extension MessageSender { return Promise(error: MessageSenderError.invalidClosedGroupUpdate) } // Generate the new encryption key pair - let newLegacyKeyPair = Curve25519.generateKeyPair() - let newKeyPair: Box.KeyPair = Box.KeyPair( - publicKey: newLegacyKeyPair.publicKey.bytes, - secretKey: newLegacyKeyPair.privateKey.bytes + let legacyNewKeyPair: ECKeyPair = Curve25519.generateKeyPair() + let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair( + threadId: closedGroup.threadId, + publicKey: legacyNewKeyPair.publicKey, + secretKey: legacyNewKeyPair.privateKey, + receivedTimestamp: Date().timeIntervalSince1970 ) // Distribute it - let proto = try SNProtoKeyPair.builder(publicKey: Data(newKeyPair.publicKey), - privateKey: Data(newKeyPair.secretKey)).build() + let proto = try SNProtoKeyPair.builder( + publicKey: newKeyPair.publicKey, + privateKey: newKeyPair.secretKey + ).build() let plaintext = try proto.serializedData() - var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? []) - distributingKeyPairs.append(newKeyPair) - distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs + distributingKeyPairs.mutate { + $0[closedGroup.id] = ($0[closedGroup.id] ?? []) + .appending(newKeyPair) + } do { return try MessageSender @@ -173,17 +178,13 @@ extension MessageSender { .done { /// Store it **after** having sent out the message to the group GRDBStorage.shared.write { db in - try ClosedGroupKeyPair( - publicKey: newKeyPair.publicKey.toHexString(), - secretKey: Data(newKeyPair.secretKey), - receivedTimestamp: Date().timeIntervalSince1970 - ).insert(db) - - var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? []) + try newKeyPair.insert(db) - if let index = distributingKeyPairs.firstIndex(of: newKeyPair) { - distributingKeyPairs.remove(at: index) - distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs + distributingKeyPairs.mutate { + if let index = ($0[closedGroup.id] ?? []).firstIndex(of: newKeyPair) { + $0[closedGroup.id] = ($0[closedGroup.id] ?? []) + .removing(index: index) + } } } } @@ -607,26 +608,19 @@ extension MessageSender { } // Get the latest encryption key pair - var maybeEncryptionKeyPair: Box.KeyPair? = distributingClosedGroupEncryptionKeyPairs[groupPublicKey]?.last + var maybeKeyPair: ClosedGroupKeyPair? = distributingKeyPairs.wrappedValue[groupPublicKey]?.last - if maybeEncryptionKeyPair == nil { - guard let encryptionKeyPair: ClosedGroupKeyPair = try? closedGroup.fetchLatestKeyPair(db) else { - return - } - - maybeEncryptionKeyPair = Box.KeyPair( - publicKey: Data(hex: encryptionKeyPair.publicKey).bytes, - secretKey: encryptionKeyPair.secretKey.bytes - ) + if maybeKeyPair == nil { + maybeKeyPair = try? closedGroup.fetchLatestKeyPair(db) } - guard let encryptionKeyPair: Box.KeyPair = maybeEncryptionKeyPair else { return } + guard let keyPair: ClosedGroupKeyPair = maybeKeyPair else { return } // Send it do { let proto = try SNProtoKeyPair.builder( - publicKey: Data(encryptionKeyPair.publicKey), - privateKey: Data(encryptionKeyPair.secretKey) + publicKey: keyPair.publicKey, + privateKey: keyPair.secretKey ).build() let plaintext = try proto.serializedData() let thread: SessionThread = try SessionThread diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index ea279c12f..8f2246a54 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -182,11 +182,14 @@ public final class MessageSender : NSObject { ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey) case .closedGroup(let groupPublicKey): - guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, publicKey: groupPublicKey) else { + guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, threadId: groupPublicKey) else { throw MessageSenderError.noKeyPair } - ciphertext = try encryptWithSessionProtocol(plaintext, for: "05\(encryptionKeyPair.publicKey)") + ciphertext = try encryptWithSessionProtocol( + plaintext, + for: "05\(encryptionKeyPair.publicKey.toHexString())" + ) case .openGroup(_, _), .openGroupV2(_, _): preconditionFailure() } @@ -357,7 +360,7 @@ public final class MessageSender : NSObject { #if DEBUG preconditionFailure() #else - handleFailure(with: Error.invalidMessage, using: transaction) + handleFailure(db, with: MessageSenderError.invalidMessage) return promise #endif } @@ -461,10 +464,16 @@ public final class MessageSender : NSObject { NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil) // Start the disappearing messages timer if needed - DisappearingMessagesJob.updateNextRunIfNeeded( + JobRunner.upsert( db, - interaction: interaction, - startedAtMs: (Date().timeIntervalSince1970 * 1000) + job: Job( + variant: .disappearingMessages, + details: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interaction: interaction, + startedAtMs: (Date().timeIntervalSince1970 * 1000) + ) + ) ) } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 61786bc41..3ff6d224f 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -109,11 +109,8 @@ public final class Poller : NSObject { messages.forEach { message in guard let envelope = SNProtoEnvelope.from(message) else { return } - // Extract the sender public key (used as the threadId in contact threads) and add - // that to the messageReceive job for multi-threading and garbage collection purposes - // - // Note: This is a slightly optimised version of the message decryption which - // just skips the validation (handled when the job actually runs) and doesn't throw + // Extract the threadId and add that to the messageReceive job for + // multi-threading and garbage collection purposes let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) if threadId == nil { diff --git a/SessionMessagingKit/Utilities/Environment.h b/SessionMessagingKit/Utilities/Environment.h index edbd77376..16b2f8004 100644 --- a/SessionMessagingKit/Utilities/Environment.h +++ b/SessionMessagingKit/Utilities/Environment.h @@ -2,7 +2,6 @@ @class OWSAudioSession; @class OWSPreferences; -@class OWSSounds; @class OWSWindowManager; @protocol OWSProximityMonitoringManager; @@ -21,13 +20,11 @@ - (instancetype)initWithAudioSession:(OWSAudioSession *)audioSession preferences:(OWSPreferences *)preferences proximityMonitoringManager:(id)proximityMonitoringManager - sounds:(OWSSounds *)sounds windowManager:(OWSWindowManager *)windowManager; @property (nonatomic, readonly) OWSAudioSession *audioSession; @property (nonatomic, readonly) id proximityMonitoringManager; @property (nonatomic, readonly) OWSPreferences *preferences; -@property (nonatomic, readonly) OWSSounds *sounds; @property (nonatomic, readonly) OWSWindowManager *windowManager; // We don't want to cover the window when we request the photo library permission @property (nonatomic, readwrite) BOOL isRequestingPermission; diff --git a/SessionMessagingKit/Utilities/Environment.m b/SessionMessagingKit/Utilities/Environment.m index 81a39ccf2..ee8f5c284 100644 --- a/SessionMessagingKit/Utilities/Environment.m +++ b/SessionMessagingKit/Utilities/Environment.m @@ -3,7 +3,6 @@ #import "OWSWindowManager.h" #import #import "OWSPreferences.h" -#import "OWSSounds.h" static Environment *sharedEnvironment = nil; @@ -12,7 +11,6 @@ static Environment *sharedEnvironment = nil; @property (nonatomic) OWSAudioSession *audioSession; @property (nonatomic) OWSPreferences *preferences; @property (nonatomic) id proximityMonitoringManager; -@property (nonatomic) OWSSounds *sounds; @property (nonatomic) OWSWindowManager *windowManager; @end @@ -44,7 +42,6 @@ static Environment *sharedEnvironment = nil; - (instancetype)initWithAudioSession:(OWSAudioSession *)audioSession preferences:(OWSPreferences *)preferences proximityMonitoringManager:(id)proximityMonitoringManager - sounds:(OWSSounds *)sounds windowManager:(OWSWindowManager *)windowManager { self = [super init]; @@ -56,7 +53,6 @@ static Environment *sharedEnvironment = nil; _audioSession = audioSession; _preferences = preferences; _proximityMonitoringManager = proximityMonitoringManager; - _sounds = sounds; _windowManager = windowManager; _isRequestingPermission = false; diff --git a/SessionMessagingKit/Utilities/OWSSounds.h b/SessionMessagingKit/Utilities/OWSSounds.h deleted file mode 100644 index 22e576ca6..000000000 --- a/SessionMessagingKit/Utilities/OWSSounds.h +++ /dev/null @@ -1,79 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import -#import -#import "OWSAudioPlayer.h" - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSUInteger, OWSSound) { - OWSSound_Default = 0, - - // Notification Sounds - OWSSound_Aurora, - OWSSound_Bamboo, - OWSSound_Chord, - OWSSound_Circles, - OWSSound_Complete, - OWSSound_Hello, - OWSSound_Input, - OWSSound_Keys, - OWSSound_Note, - OWSSound_Popcorn, - OWSSound_Pulse, - OWSSound_Synth, - OWSSound_SignalClassic, - - // Ringtone Sounds - OWSSound_Opening, - - // Calls - OWSSound_CallConnecting, - OWSSound_CallOutboundRinging, - OWSSound_CallBusy, - OWSSound_CallFailure, - - // Other - OWSSound_MessageSent, - OWSSound_None, - OWSSound_DefaultiOSIncomingRingtone = OWSSound_Opening, -}; - -@class OWSAudioPlayer; -@class OWSPrimaryStorage; -@class TSThread; -@class YapDatabaseReadWriteTransaction; - -@interface OWSSounds : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage NS_DESIGNATED_INITIALIZER; - -+ (NSString *)displayNameForSound:(OWSSound)sound; - -+ (nullable NSString *)filenameForSound:(OWSSound)sound; -+ (nullable NSString *)filenameForSound:(OWSSound)sound quiet:(BOOL)quiet; - -#pragma mark - Notifications - -+ (NSArray *)allNotificationSounds; - -+ (OWSSound)globalNotificationSound; -+ (void)setGlobalNotificationSound:(OWSSound)sound; -+ (void)setGlobalNotificationSound:(OWSSound)sound transaction:(YapDatabaseReadWriteTransaction *)transaction; - -+ (OWSSound)notificationSoundForThread:(TSThread *)thread; -+ (SystemSoundID)systemSoundIDForSound:(OWSSound)sound quiet:(BOOL)quiet; -+ (void)setNotificationSound:(OWSSound)sound forThread:(TSThread *)thread; - -#pragma mark - AudioPlayer - -+ (nullable OWSAudioPlayer *)audioPlayerForSound:(OWSSound)sound - audioBehavior:(OWSAudioBehavior)audioBehavior; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSSounds.m b/SessionMessagingKit/Utilities/OWSSounds.m deleted file mode 100644 index 4415402db..000000000 --- a/SessionMessagingKit/Utilities/OWSSounds.m +++ /dev/null @@ -1,365 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSSounds.h" -#import "Environment.h" -#import "OWSAudioPlayer.h" -#import -#import -#import -#import -#import - -NSString *const kOWSSoundsStorageNotificationCollection = @"kOWSSoundsStorageNotificationCollection"; -NSString *const kOWSSoundsStorageGlobalNotificationKey = @"kOWSSoundsStorageGlobalNotificationKey"; - -@interface OWSSystemSound : NSObject - -@property (nonatomic, readonly) SystemSoundID soundID; -@property (nonatomic, readonly) NSURL *soundURL; - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithURL:(NSURL *)url NS_DESIGNATED_INITIALIZER; - -@end - -@implementation OWSSystemSound - -- (instancetype)initWithURL:(NSURL *)url -{ - self = [super init]; - - if (!self) { - return self; - } - - _soundURL = url; - - SystemSoundID newSoundID; - _soundID = newSoundID; - - return self; -} - -- (void)dealloc -{ - -} - -@end - -@interface OWSSounds () - -@property (nonatomic, readonly) YapDatabaseConnection *dbConnection; -@property (nonatomic, readonly) AnyLRUCache *cachedSystemSounds; - -@end - -#pragma mark - - -@implementation OWSSounds - -+ (instancetype)sharedManager -{ - return Environment.shared.sounds; -} - -- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage -{ - self = [super init]; - - if (!self) { - return self; - } - - _dbConnection = primaryStorage.newDatabaseConnection; - - // Don't store too many sounds in memory. Most users will only use 1 or 2 sounds anyway. - _cachedSystemSounds = [[AnyLRUCache alloc] initWithMaxSize:4]; - - return self; -} - -+ (NSArray *)allNotificationSounds -{ - return @[ - // None and Note (default) should be first. - @(OWSSound_None), - @(OWSSound_Note), - - @(OWSSound_Aurora), - @(OWSSound_Bamboo), - @(OWSSound_Chord), - @(OWSSound_Circles), - @(OWSSound_Complete), - @(OWSSound_Hello), - @(OWSSound_Input), - @(OWSSound_Keys), - @(OWSSound_Popcorn), - @(OWSSound_Pulse), - @(OWSSound_Synth), - ]; -} - -+ (NSString *)displayNameForSound:(OWSSound)sound -{ - // TODO: Should we localize these sound names? - switch (sound) { - case OWSSound_Default: - return @""; - - // Notification Sounds - case OWSSound_Aurora: - return @"Aurora"; - case OWSSound_Bamboo: - return @"Bamboo"; - case OWSSound_Chord: - return @"Chord"; - case OWSSound_Circles: - return @"Circles"; - case OWSSound_Complete: - return @"Complete"; - case OWSSound_Hello: - return @"Hello"; - case OWSSound_Input: - return @"Input"; - case OWSSound_Keys: - return @"Keys"; - case OWSSound_Note: - return @"Note"; - case OWSSound_Popcorn: - return @"Popcorn"; - case OWSSound_Pulse: - return @"Pulse"; - case OWSSound_Synth: - return @"Synth"; - case OWSSound_SignalClassic: - return @"Signal Classic"; - - // Call Audio - case OWSSound_Opening: - return @"Opening"; - case OWSSound_CallConnecting: - return @"Call Connecting"; - case OWSSound_CallOutboundRinging: - return @"Call Outboung Ringing"; - case OWSSound_CallBusy: - return @"Call Busy"; - case OWSSound_CallFailure: - return @"Call Failure"; - case OWSSound_MessageSent: - return @"Message Sent"; - - // Other - case OWSSound_None: - return NSLocalizedString(@"SOUNDS_NONE", - @"Label for the 'no sound' option that allows users to disable sounds for notifications, " - @"etc."); - } -} - -+ (nullable NSString *)filenameForSound:(OWSSound)sound -{ - return [self filenameForSound:sound quiet:NO]; -} - -+ (nullable NSString *)filenameForSound:(OWSSound)sound quiet:(BOOL)quiet -{ - switch (sound) { - case OWSSound_Default: - return @""; - - // Notification Sounds - case OWSSound_Aurora: - return (quiet ? @"aurora-quiet.aifc" : @"aurora.aifc"); - case OWSSound_Bamboo: - return (quiet ? @"bamboo-quiet.aifc" : @"bamboo.aifc"); - case OWSSound_Chord: - return (quiet ? @"chord-quiet.aifc" : @"chord.aifc"); - case OWSSound_Circles: - return (quiet ? @"circles-quiet.aifc" : @"circles.aifc"); - case OWSSound_Complete: - return (quiet ? @"complete-quiet.aifc" : @"complete.aifc"); - case OWSSound_Hello: - return (quiet ? @"hello-quiet.aifc" : @"hello.aifc"); - case OWSSound_Input: - return (quiet ? @"input-quiet.aifc" : @"input.aifc"); - case OWSSound_Keys: - return (quiet ? @"keys-quiet.aifc" : @"keys.aifc"); - case OWSSound_Note: - return (quiet ? @"note-quiet.aifc" : @"note.aifc"); - case OWSSound_Popcorn: - return (quiet ? @"popcorn-quiet.aifc" : @"popcorn.aifc"); - case OWSSound_Pulse: - return (quiet ? @"pulse-quiet.aifc" : @"pulse.aifc"); - case OWSSound_Synth: - return (quiet ? @"synth-quiet.aifc" : @"synth.aifc"); - case OWSSound_SignalClassic: - return (quiet ? @"classic-quiet.aifc" : @"classic.aifc"); - - // Ringtone Sounds - case OWSSound_Opening: - return @"Opening.m4r"; - - // Calls - case OWSSound_CallConnecting: - return @"ringback_tone_ansi.caf"; - case OWSSound_CallOutboundRinging: - return @"ringback_tone_ansi.caf"; - case OWSSound_CallBusy: - return @"busy_tone_ansi.caf"; - case OWSSound_CallFailure: - return @"end_call_tone_cept.caf"; - case OWSSound_MessageSent: - return @"message_sent.aiff"; - - // Other - case OWSSound_None: - return nil; - } -} - -+ (nullable NSURL *)soundURLForSound:(OWSSound)sound quiet:(BOOL)quiet -{ - NSString *_Nullable filename = [self filenameForSound:sound quiet:quiet]; - if (!filename) { - return nil; - } - NSURL *_Nullable url = [[NSBundle mainBundle] URLForResource:filename.stringByDeletingPathExtension - withExtension:filename.pathExtension]; - return url; -} - -+ (SystemSoundID)systemSoundIDForSound:(OWSSound)sound quiet:(BOOL)quiet -{ - return [self.sharedManager systemSoundIDForSound:(OWSSound)sound quiet:quiet]; -} - -- (SystemSoundID)systemSoundIDForSound:(OWSSound)sound quiet:(BOOL)quiet -{ - NSString *cacheKey = [NSString stringWithFormat:@"%lu:%d", (unsigned long)sound, quiet]; - OWSSystemSound *_Nullable cachedSound = (OWSSystemSound *)[self.cachedSystemSounds getWithKey:cacheKey]; - - if (cachedSound) { - return cachedSound.soundID; - } - - NSURL *soundURL = [self.class soundURLForSound:sound quiet:quiet]; - OWSSystemSound *newSound = [[OWSSystemSound alloc] initWithURL:soundURL]; - [self.cachedSystemSounds setWithKey:cacheKey value:newSound]; - - return newSound.soundID; -} - -#pragma mark - Notifications - -+ (OWSSound)defaultNotificationSound -{ - return OWSSound_Note; -} - -+ (OWSSound)globalNotificationSound -{ - OWSSounds *instance = OWSSounds.sharedManager; - NSNumber *_Nullable value = [instance.dbConnection objectForKey:kOWSSoundsStorageGlobalNotificationKey - inCollection:kOWSSoundsStorageNotificationCollection]; - // Default to the global default. - return (value ? (OWSSound)value.intValue : [self defaultNotificationSound]); -} - -+ (void)setGlobalNotificationSound:(OWSSound)sound -{ - [self.sharedManager setGlobalNotificationSound:sound]; -} - -- (void)setGlobalNotificationSound:(OWSSound)sound -{ - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [self setGlobalNotificationSound:sound transaction:transaction]; - }]; -} - -+ (void)setGlobalNotificationSound:(OWSSound)sound transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - [self.sharedManager setGlobalNotificationSound:sound transaction:transaction]; -} - -- (void)setGlobalNotificationSound:(OWSSound)sound transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - // Fallback push notifications play a sound specified by the server, but we don't want to store this configuration - // on the server. Instead, we create a file with the same name as the default to be played when receiving - // a fallback notification. - NSString *dirPath = [[OWSFileSystem appLibraryDirectoryPath] stringByAppendingPathComponent:@"Sounds"]; - [OWSFileSystem ensureDirectoryExists:dirPath]; - - // This name is specified in the payload by the Signal Service when requesting fallback push notifications. - NSString *kDefaultNotificationSoundFilename = @"NewMessage.aifc"; - NSString *defaultSoundPath = [dirPath stringByAppendingPathComponent:kDefaultNotificationSoundFilename]; - - NSURL *_Nullable soundURL = [OWSSounds soundURLForSound:sound quiet:NO]; - - NSData *soundData = ^{ - if (soundURL) { - return [NSData dataWithContentsOfURL:soundURL]; - } else { - return [NSData new]; - } - }(); - - // Quick way to achieve an atomic "copy" operation that allows overwriting if the user has previously specified - // a default notification sound. - BOOL success = [soundData writeToFile:defaultSoundPath atomically:YES]; - - // The globally configured sound the user has configured is unprotected, so that we can still play the sound if the - // user hasn't authenticated after power-cycling their device. - [OWSFileSystem protectFileOrFolderAtPath:defaultSoundPath fileProtectionType:NSFileProtectionNone]; - - if (!success) { - return; - } - - [transaction setObject:@(sound) - forKey:kOWSSoundsStorageGlobalNotificationKey - inCollection:kOWSSoundsStorageNotificationCollection]; -} - -+ (OWSSound)notificationSoundForThread:(TSThread *)thread -{ - OWSSounds *instance = OWSSounds.sharedManager; - NSNumber *_Nullable value = - [instance.dbConnection objectForKey:thread.uniqueId inCollection:kOWSSoundsStorageNotificationCollection]; - // Default to the "global" notification sound, which in turn will default to the global default. - return (value ? (OWSSound)value.intValue : [self globalNotificationSound]); -} - -+ (void)setNotificationSound:(OWSSound)sound forThread:(TSThread *)thread -{ - OWSSounds *instance = OWSSounds.sharedManager; - [instance.dbConnection setObject:@(sound) - forKey:thread.uniqueId - inCollection:kOWSSoundsStorageNotificationCollection]; -} - -#pragma mark - AudioPlayer - -+ (BOOL)shouldAudioPlayerLoopForSound:(OWSSound)sound -{ - return (sound == OWSSound_CallConnecting || sound == OWSSound_CallOutboundRinging); -} - -+ (nullable OWSAudioPlayer *)audioPlayerForSound:(OWSSound)sound - audioBehavior:(OWSAudioBehavior)audioBehavior; -{ - NSURL *_Nullable soundURL = [OWSSounds soundURLForSound:sound quiet:NO]; - if (!soundURL) { - return nil; - } - OWSAudioPlayer *player = [[OWSAudioPlayer alloc] initWithMediaUrl:soundURL audioBehavior:audioBehavior]; - if ([self shouldAudioPlayerLoopForSound:sound]) { - player.isLooping = YES; - } - return player; -} - -@end diff --git a/SessionMessagingKit/Utilities/OWSSounds.swift b/SessionMessagingKit/Utilities/OWSSounds.swift deleted file mode 100644 index 97caad633..000000000 --- a/SessionMessagingKit/Utilities/OWSSounds.swift +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SignalCoreKit - -extension OWSSound { - - public func notificationSound(isQuiet: Bool) -> UNNotificationSound { - guard let filename = OWSSounds.filename(for: self, quiet: isQuiet) else { - owsFailDebug("filename was unexpectedly nil") - return UNNotificationSound.default - } - return UNNotificationSound(named: UNNotificationSoundName(rawValue: filename)) - } -} diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 5309e0142..557f4198c 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import SessionUtilitiesKit +import AudioToolbox public extension Setting.EnumKey { /// Controls how notifications should appear for the user (See `NotificationPreviewType` for the options) @@ -66,8 +67,15 @@ public enum Preferences { } public enum Sound: Int, Codable, DatabaseValueConvertible, EnumSetting { - static var defaultiOSIncomingRingtone: Sound = .opening - static var defaultNotificationSound: Sound = .note + public static var defaultiOSIncomingRingtone: Sound = .opening + public static var defaultNotificationSound: Sound = .note + + // Don't store too many sounds in memory (Most users will only use 1 or 2 sounds anyway) + private static let maxCachedSounds: Int = 4 + private static var cachedSystemSounds: Atomic<[String: (url: URL?, soundId: SystemSoundID)]> = Atomic([:]) + private static var cachedSystemSoundOrder: Atomic<[String]> = Atomic([]) + + // Values case `default` @@ -99,7 +107,7 @@ public enum Preferences { case messageSent = 4000 case none - static var notificationSounds: [Sound] { + public static var notificationSounds: [Sound] { return [ // None and Note (default) should be first. .none, @@ -118,5 +126,202 @@ public enum Preferences { .synth ] } + + var displayName: String { + // TODO: Should we localize these sound names? + switch self { + case .`default`: return "" + + // Notification Sounds + case .aurora: return "Aurora" + case .bamboo: return "Bamboo" + case .chord: return "Chord" + case .circles: return "Circles" + case .complete: return "Complete" + case .hello: return "Hello" + case .input: return "Input" + case .keys: return "Keys" + case .note: return "Note" + case .popcorn: return "Popcorn" + case .pulse: return "Pulse" + case .synth: return "Synth" + case .signalClassic: return "Signal Classic" + + // Ringtone Sounds + case .opening: return "Opening" + + // Calls + case .callConnecting: return "Call Connecting" + case .callOutboundRinging: return "Call Outboung Ringing" + case .callBusy: return "Call Busy" + case .callFailure: return "Call Failure" + + // Other + case .messageSent: return "Message Sent" + case .none: return "SOUNDS_NONE".localized() + } + } + + // MARK: - Functions + + public func filename(quiet: Bool = false) -> String? { + switch self { + case .`default`: return "" + + // Notification Sounds + case .aurora: return (quiet ? "aurora-quiet.aifc" : "aurora.aifc") + case .bamboo: return (quiet ? "bamboo-quiet.aifc" : "bamboo.aifc") + case .chord: return (quiet ? "chord-quiet.aifc" : "chord.aifc") + case .circles: return (quiet ? "circles-quiet.aifc" : "circles.aifc") + case .complete: return (quiet ? "complete-quiet.aifc" : "complete.aifc") + case .hello: return (quiet ? "hello-quiet.aifc" : "hello.aifc") + case .input: return (quiet ? "input-quiet.aifc" : "input.aifc") + case .keys: return (quiet ? "keys-quiet.aifc" : "keys.aifc") + case .note: return (quiet ? "note-quiet.aifc" : "note.aifc") + case .popcorn: return (quiet ? "popcorn-quiet.aifc" : "popcorn.aifc") + case .pulse: return (quiet ? "pulse-quiet.aifc" : "pulse.aifc") + case .synth: return (quiet ? "synth-quiet.aifc" : "synth.aifc") + case .signalClassic: return (quiet ? "classic-quiet.aifc" : "classic.aifc") + + // Ringtone Sounds + case .opening: return "Opening.m4r" + + // Calls + case .callConnecting: return "ringback_tone_ansi.caf" + case .callOutboundRinging: return "ringback_tone_ansi.caf" + case .callBusy: return "busy_tone_ansi.caf" + case .callFailure: return "end_call_tone_cept.caf" + + // Other + case .messageSent: return "message_sent.aiff" + case .none: return nil + } + } + + public func soundUrl(quiet: Bool = false) -> URL? { + guard let filename: String = filename(quiet: quiet) else { return nil } + + let url: URL = URL(fileURLWithPath: filename) + + return Bundle.main.url( + forResource: url.deletingPathExtension().absoluteString, + withExtension: url.pathExtension + ) + } + + public func notificationSound(isQuiet: Bool) -> UNNotificationSound { + guard let filename: String = filename(quiet: isQuiet) else { + SNLog("[Preferences.Sound] filename was unexpectedly nil") + return UNNotificationSound.default + } + + return UNNotificationSound(named: UNNotificationSoundName(rawValue: filename)) + } + + public static func systemSoundId(for sound: Sound, quiet: Bool) -> SystemSoundID { + let cacheKey: String = "\(sound.rawValue):\(quiet ? 1 : 0)" + + if let cachedSound: SystemSoundID = cachedSystemSounds.wrappedValue[cacheKey]?.soundId { + return cachedSound + } + + let systemSound: (url: URL?, soundId: SystemSoundID) = ( + url: sound.soundUrl(quiet: quiet), + soundId: SystemSoundID() + ) + + cachedSystemSounds.mutate { cache in + cachedSystemSoundOrder.mutate { order in + if order.count > Sound.maxCachedSounds { + cache.removeValue(forKey: order[0]) + order.remove(at: 0) + } + + order.append(cacheKey) + } + + cache[cacheKey] = systemSound + } + + return systemSound.soundId + } + + // MARK: - AudioPlayer + + public static func audioPlayer(for sound: Sound, behaviour: OWSAudioBehavior) -> OWSAudioPlayer? { + guard let soundUrl: URL = sound.soundUrl(quiet: false) else { return nil } + + let player = OWSAudioPlayer(mediaUrl: soundUrl, audioBehavior: behaviour) + + // These two cases should loop + if sound == .callConnecting || sound == .callOutboundRinging { + player.isLooping = true + } + + return player + } + } +} + +// MARK: - Objective C Support + +// FIXME: Remove this once the 'NotificationSettingsViewController' and 'OWSSoundSettingsViewController' have been refactored to Swift +@objc(SMKSound) +public class SMKSound: NSObject { + @objc public static var notificationSounds: [Int] = Preferences.Sound.notificationSounds.map { $0.rawValue } + + @objc public static func displayName(for sound: Int) -> String { + return (Preferences.Sound(rawValue: sound) ?? Preferences.Sound.default).displayName + } + + @objc public static func isNote(_ sound: Int) -> Bool { + return (sound == Preferences.Sound.note.rawValue) + } + + @objc public static func audioPlayer(for sound: Int, audioBehavior: OWSAudioBehavior) -> OWSAudioPlayer? { + guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return nil } + + return Preferences.Sound.audioPlayer(for: sound, behaviour: audioBehavior) + } + + @objc public static var defaultNotificationSound: Int { + GRDBStorage.shared[.defaultNotificationSound] + .defaulting(to: Preferences.Sound.defaultNotificationSound) + .rawValue + } + + @objc public static func setGlobalNotificationSound(_ sound: Int) { + guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return } + + GRDBStorage.shared.write { db in + db[.defaultNotificationSound] = sound + } + } + + @objc public static func notificationSound(for threadId: String?) -> Int { + guard let threadId: String = threadId else { return defaultNotificationSound } + + return (GRDBStorage.shared + .read { db in + try Preferences.Sound + .fetchOne( + db, + SessionThread + .select(SessionThread.Columns.notificationSound) + .filter(id: threadId) + ) + }? + .rawValue) + .defaulting(to: defaultNotificationSound) + } + + @objc public static func setNotificationSound(_ sound: Int, forThreadId threadId: String) { + guard let sound: Preferences.Sound = Preferences.Sound(rawValue: sound) else { return } + + GRDBStorage.shared.write { db in + try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.notificationSound.set(to: sound)) + } } } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index dbbe189e5..5a5343d56 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -74,7 +74,8 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo - notificationContent.sound = OWSSounds.notificationSound(forThreadId: thread.id) + notificationContent.sound = thread.notificationSound + .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) .notificationSound(isQuiet: false) // Badge Number diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 480cf932f..3ebf1d214 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -70,89 +70,61 @@ public extension Identity { } static func userExists(_ db: Database? = nil) -> Bool { - let userExists: (Database) -> Bool = { db in - return ( - (try? Identity.fetchOne(db, id: .x25519PublicKey)) != nil && - (try? Identity.fetchOne(db, id: .x25519PrivateKey)) != nil - ) - } - - if let db: Database = db { - return userExists(db) - } - - return GRDBStorage.shared - .read { db -> Bool in userExists(db) } - .defaulting(to: false) + return (fetchUserKeyPair(db) != nil) } static func fetchUserPublicKey(_ db: Database? = nil) -> Data? { - if let db: Database = db { - return try? Identity.fetchOne(db, id: .x25519PublicKey)?.data + guard let db: Database = db else { + return GRDBStorage.shared.read { db in fetchUserPublicKey(db) } } - return GRDBStorage.shared.read { db -> Data? in - try Identity.fetchOne(db, id: .x25519PublicKey)?.data - } + return try? Identity.fetchOne(db, id: .x25519PublicKey)?.data } static func fetchUserPrivateKey(_ db: Database? = nil) -> Data? { - if let db: Database = db { - return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data + guard let db: Database = db else { + return GRDBStorage.shared.read { db in fetchUserPrivateKey(db) } } - return GRDBStorage.shared.read { db -> Data? in - try Identity.fetchOne(db, id: .x25519PrivateKey)?.data - } + return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data } static func fetchUserKeyPair(_ db: Database? = nil) -> Box.KeyPair? { - let fetchKeys: (Database) -> Box.KeyPair? = { db in - guard - let publicKey: Identity = try? Identity.fetchOne(db, id: .x25519PublicKey), - let privateKey: Identity = try? Identity.fetchOne(db, id: .x25519PrivateKey) - else { - return nil - } - - return Box.KeyPair( - publicKey: publicKey.data.bytes, - secretKey: privateKey.data.bytes - ) - } - - if let db: Database = db { - return fetchKeys(db) + guard let db: Database = db else { + return GRDBStorage.shared.read { db in fetchUserKeyPair(db) } } + guard + let publicKey: Data = fetchUserPublicKey(db), + let privateKey: Data = fetchUserPrivateKey(db) + else { return nil } - return GRDBStorage.shared.read { db -> Box.KeyPair? in - return fetchKeys(db) - } + return Box.KeyPair( + publicKey: publicKey.bytes, + secretKey: privateKey.bytes + ) } static func fetchUserEd25519KeyPair() -> Box.KeyPair? { return GRDBStorage.shared.read { db -> Box.KeyPair? in guard - let publicKey: Identity = try? Identity.fetchOne(db, id: .ed25519PublicKey), - let secretKey: Identity = try? Identity.fetchOne(db, id: .ed25519SecretKey) - else { - return nil - } + let publicKey: Data = try? Identity.fetchOne(db, id: .ed25519PublicKey)?.data, + let secretKey: Data = try? Identity.fetchOne(db, id: .ed25519SecretKey)?.data + else { return nil } return Box.KeyPair( - publicKey: publicKey.data.bytes, - secretKey: secretKey.data.bytes + publicKey: publicKey.bytes, + secretKey: secretKey.bytes ) } } static func fetchHexEncodedSeed() -> String? { return GRDBStorage.shared.read { db in - guard let value: Identity = try? Identity.fetchOne(db, id: .seed) else { + guard let data: Data = try? Identity.fetchOne(db, id: .seed)?.data else { return nil } - return value.data.toHexString() + return data.toHexString() } } diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index 4cf28c67a..01029f2a3 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -30,8 +30,14 @@ extension Setting { self.value = Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) } - fileprivate func value(as type: T.Type) -> T { - return value.withUnsafeBytes { $0.load(as: T.self) } + fileprivate func value(as type: T.Type) -> T? { + // 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' + // method which crashed under certain circumstances (an `Int` value of 0) + return value.withUnsafeBytes { + $0.baseAddress?.assumingMemoryBound(to: T.self).pointee + } } } diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 887bf9052..30575df00 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -23,6 +23,12 @@ public extension Array { return updatedArray } + func removing(index: Int) -> [Element] { + var updatedArray: [Element] = self + updatedArray.remove(at: index) + return updatedArray + } + mutating func popFirst() -> Element? { guard !self.isEmpty else { return nil } diff --git a/SessionUtilitiesKit/General/LRUCache.swift b/SessionUtilitiesKit/General/LRUCache.swift index 8e2dde882..184558373 100644 --- a/SessionUtilitiesKit/General/LRUCache.swift +++ b/SessionUtilitiesKit/General/LRUCache.swift @@ -2,32 +2,6 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -@objc -public class AnyLRUCache: NSObject { - - private let backingCache: LRUCache - - @objc - public init(maxSize: Int) { - backingCache = LRUCache(maxSize: maxSize) - } - - @objc - public func get(key: NSObject) -> NSObject? { - return self.backingCache.get(key: key) - } - - @objc - public func set(key: NSObject, value: NSObject) { - self.backingCache.set(key: key, value: value) - } - - @objc - public func clear() { - self.backingCache.clear() - } -} - // A simple LRU cache bounded by the number of entries. public class LRUCache { diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index 591b18346..a95fd0e95 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -10,7 +10,6 @@ #import #import #import -#import #import #import #import @@ -59,14 +58,12 @@ NS_ASSUME_NONNULL_BEGIN id typingIndicators = [[OWSTypingIndicatorsImpl alloc] init]; OWSAudioSession *audioSession = [OWSAudioSession new]; - OWSSounds *sounds = [[OWSSounds alloc] initWithPrimaryStorage:primaryStorage]; id proximityMonitoringManager = [OWSProximityMonitoringManagerImpl new]; OWSWindowManager *windowManager = [[OWSWindowManager alloc] initDefault]; [Environment setShared:[[Environment alloc] initWithAudioSession:audioSession preferences:preferences proximityMonitoringManager:proximityMonitoringManager - sounds:sounds windowManager:windowManager]]; // TODO: Add this back