From 4380f1975cac4f5f6cf57762b8ac737ffd72291d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 8 Apr 2022 16:56:33 +1000 Subject: [PATCH] Further work on the DB refactoring Added the rest of the interaction structure to the database (testing some migration logic now - still needs to be finalised) Updated the YDBToGRDB migrations to wrap their inserts in autorelease pools (helps memory slightly, unfortunately it's caching the YDB data which uses the most memory but we have opted for speed over RAM at the moment) Updated the MockDataGenerator so it should now "chunk" the code generation (crazy large figures were previously resulting in excessive memory usage) --- Session.xcodeproj/project.pbxproj | 20 + Session/Home/HomeVC.swift | 4 +- Session/Notifications/AppNotifications.swift | 2 +- Session/Utilities/BackgroundPoller.swift | 5 + Session/Utilities/MockDataGenerator.swift | 451 ++++++++++-------- .../LegacyDatabase/SMKLegacyModels.swift | 94 ++++ .../_001_InitialSetupMigration.swift | 160 ++++++- .../Migrations/_002_YDBToGRDBMigration.swift | 446 +++++++++++++---- .../Database/Models/Attachment.swift | 131 +++++ .../Database/Models/Capability.swift | 14 +- .../Database/Models/ClosedGroup.swift | 35 +- .../Database/Models/ClosedGroupKeyPair.swift | 11 + .../DisappearingMessageConfiguration.swift | 14 +- .../Database/Models/GroupMember.swift | 20 + .../Database/Models/Interaction.swift | 211 ++++++++ .../Database/Models/LinkPreview.swift | 38 ++ .../Database/Models/OpenGroup.swift | 76 ++- .../Database/Models/Profile.swift | 3 + .../Database/Models/Quote.swift | 57 +++ .../Database/Models/RecipientState.swift | 55 +++ .../Database/Models/SessionThread.swift | 27 +- .../Messages/Signal/TSOutgoingMessage.h | 2 + .../Messages/Signal/TSOutgoingMessage.m | 1 - .../Migrations/_002_YDBToGRDBMigration.swift | 64 +-- .../Migrations/_002_YDBToGRDBMigration.swift | 53 +- .../Database/Types/TypedTableDefinition.swift | 4 + SignalUtilitiesKit/Configuration.swift | 8 +- SignalUtilitiesKit/Utilities/AppSetup.m | 3 + 28 files changed, 1610 insertions(+), 399 deletions(-) create mode 100644 SessionMessagingKit/Database/Models/Attachment.swift create mode 100644 SessionMessagingKit/Database/Models/Interaction.swift create mode 100644 SessionMessagingKit/Database/Models/LinkPreview.swift create mode 100644 SessionMessagingKit/Database/Models/Quote.swift create mode 100644 SessionMessagingKit/Database/Models/RecipientState.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 33dfe7c0c..6db2a1dbc 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -753,6 +753,10 @@ FD09798B27FD1CFE00936362 /* Capability.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798A27FD1CFE00936362 /* Capability.swift */; }; FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09798C27FD1D8900936362 /* DisappearingMessageConfiguration.swift */; }; FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */; }; + FD09799527FE7B8E00936362 /* Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799227FE693200936362 /* Interaction.swift */; }; + FD09799727FFA84A00936362 /* RecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799627FFA84900936362 /* RecipientState.swift */; }; + FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; }; + FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -798,6 +802,7 @@ FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; FDC4389E27BA2B8A00C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; + FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.swift */; }; /* End PBXBuildFile section */ @@ -1800,6 +1805,10 @@ FD09798A27FD1CFE00936362 /* Capability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Capability.swift; sourceTree = ""; }; FD09798C27FD1D8900936362 /* DisappearingMessageConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessageConfiguration.swift; sourceTree = ""; }; FD09799027FD499200936362 /* BoxKeyPair+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BoxKeyPair+Utilities.swift"; sourceTree = ""; }; + FD09799227FE693200936362 /* Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interaction.swift; sourceTree = ""; }; + FD09799627FFA84900936362 /* RecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientState.swift; sourceTree = ""; }; + FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -1845,6 +1854,7 @@ FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = ""; }; FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; @@ -3610,6 +3620,11 @@ FD09798827FD1C5A00936362 /* OpenGroup.swift */, FD09798627FD1B7800936362 /* GroupMember.swift */, FD09798A27FD1CFE00936362 /* Capability.swift */, + FD09799227FE693200936362 /* Interaction.swift */, + FD09799627FFA84900936362 /* RecipientState.swift */, + FD09799827FFC1A300936362 /* Attachment.swift */, + FD09799A27FFC82D00936362 /* Quote.swift */, + FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, ); path = Models; sourceTree = ""; @@ -4842,6 +4857,7 @@ C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */, + FD09799927FFC1A300936362 /* Attachment.swift in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, C352A32F2557549C00338F3E /* NotifyPNServerJob.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, @@ -4860,6 +4876,7 @@ C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, + FD09799727FFA84A00936362 /* RecipientState.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, @@ -4872,6 +4889,7 @@ B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, + FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, @@ -4930,6 +4948,7 @@ C3DB66CC260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift in Sources */, C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, + FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */, C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */, @@ -4958,6 +4977,7 @@ B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */, C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */, + FD09799B27FFC82D00936362 /* Quote.swift in Sources */, C3A3A18A256E2092004D228D /* SignalRecipient.m in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, C32C5F11256DF79A003C73A2 /* SSKIncrementingIdFinder.swift in Sources */, diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 6d64b792d..4f8b8bfa3 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -396,7 +396,9 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } @objc private func handleProfileDidChangeNotification(_ notification: Notification) { - tableView.reloadData() // TODO: Just reload the affected cell + DispatchQueue.main.async { + self.tableView.reloadData() // TODO: Just reload the affected cell + } } @objc private func handleLocalProfileDidChangeNotification(_ notification: Notification) { diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 0f8d0f06d..36842635d 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -197,7 +197,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return } - let senderName = Profile.displayName(for: incomingMessage.authorId, thread: incomingMessage.thread) + let senderName = Profile.displayName(for: incomingMessage.authorId, thread: thread) let notificationTitle: String? var notificationBody: String? diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index ff572ac87..bbdd9d9f8 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import PromiseKit import SessionSnodeKit +import SessionMessagingKit @objc(LKBackgroundPoller) public final class BackgroundPoller : NSObject { @@ -13,6 +17,7 @@ public final class BackgroundPoller : NSObject { promises = [] promises.append(pollForMessages()) promises.append(contentsOf: pollForClosedGroupMessages()) + let v2OpenGroupServers = Set(Storage.shared.getAllV2OpenGroups().values.map { $0.server }) v2OpenGroupServers.forEach { server in let poller = OpenGroupPollerV2(for: server) diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 1074d1669..a3e98db18 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -96,6 +96,11 @@ enum MockDataGenerator { let dmRandomSeed: Int = 1111 let cgRandomSeed: Int = 2222 let ogRandomSeed: Int = 3333 + let chunkSize: Int = 1000 // Chunk up the thread writing to prevent memory issues + let stringContent: [String] = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { String($0) } + let wordContent: [String] = ["alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat"] + let timestampNow: TimeInterval = Date().timeIntervalSince1970 + let userSessionId: String = getUserHexEncodedPublicKey() let logProgress: (String, String) -> () = { title, event in guard printProgress else { return } @@ -105,240 +110,270 @@ enum MockDataGenerator { hasStartedGenerationThisRun = true // FIXME: Make sure this data doesn't go off device somehow? - Storage.shared.write { anyTransaction in - guard let transaction: YapDatabaseReadWriteTransaction = anyTransaction as? YapDatabaseReadWriteTransaction else { - return - } - - // First create the thread used to indicate that the mock data has been generated - logProgress("", "Start") + logProgress("", "Start") + + // First create the thread used to indicate that the mock data has been generated + Storage.write { transaction in _ = TSContactThread.getOrCreateThread(withContactSessionID: "MockDatabaseThread", transaction: transaction) + } + + // MARK: - -- DM Thread + + var dmThreadRandomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: dmRandomSeed) + var dmThreadIndex: Int = 0 + logProgress("DM Threads", "Start Generating \(dmThreadCount) threads") + + while dmThreadIndex < dmThreadCount { + let remainingThreads: Int = (dmThreadCount - dmThreadIndex) - // Multiple spaces to make it look more like words - let stringContent: [String] = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { String($0) } - let wordContent: [String] = ["alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat"] - let timestampNow: TimeInterval = Date().timeIntervalSince1970 - let userSessionId: String = getUserHexEncodedPublicKey() - - // MARK: - -- DM Thread - var dmThreadRandomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: dmRandomSeed) - - (0.. Context { return ((thread as? TSGroupThread)?.isOpenGroup == true) ? .openGroup : .regular + +@objc(OWSDisappearingMessagesConfiguration) +public class _LegacyDisappearingMessagesConfiguration: MTLModel { + public let uniqueId: String + @objc public var isEnabled: Bool + @objc public var durationSeconds: UInt32 + + @objc public var durationIndex: UInt32 = 0 + @objc public var durationString: String? + + var originalDictionaryValue: [String: Any]? + @objc public var isNewRecord: Bool = false + + @objc public static func defaultWith(_ threadId: String) -> Legacy.DisappearingMessagesConfiguration { + return Legacy.DisappearingMessagesConfiguration( + threadId: threadId, + enabled: false, + durationSeconds: (24 * 60 * 60) + ) + } + + public static func fetch(uniqueId: String, transaction: YapDatabaseReadTransaction? = nil) -> Legacy.DisappearingMessagesConfiguration? { + return nil + } + + @objc public static func fetchObject(uniqueId: String) -> Legacy.DisappearingMessagesConfiguration? { + return nil + } + + @objc public static func fetchOrBuildDefault(threadId: String, transaction: YapDatabaseReadTransaction) -> Legacy.DisappearingMessagesConfiguration? { + return defaultWith(threadId) + } + + @objc public static var validDurationsSeconds: [UInt32] = [] + + // MARK: - Initialization + + init(threadId: String, enabled: Bool, durationSeconds: UInt32) { + self.uniqueId = threadId + self.isEnabled = enabled + self.durationSeconds = durationSeconds + self.isNewRecord = true + + super.init() + } + + required init(coder: NSCoder) { + self.uniqueId = coder.decodeObject(forKey: "uniqueId") as! String + self.isEnabled = coder.decodeObject(forKey: "enabled") as! Bool + self.durationSeconds = coder.decodeObject(forKey: "durationSeconds") as! UInt32 + + // Intentionally not calling 'super.init(coder:) here + super.init() + } + + required init(dictionary dictionaryValue: [String : Any]!) throws { + fatalError("init(dictionary:) has not been implemented") + } + + // MARK: - Dirty Tracking + + @objc public override static func storageBehaviorForProperty(withKey propertyKey: String) -> MTLPropertyStorage { + // Don't persist transient properties + if + propertyKey == "TAG" || + propertyKey == "originalDictionaryValue" || + propertyKey == "newRecord" + { + return MTLPropertyStorageNone + } + + return super.storageBehaviorForProperty(withKey: propertyKey) + } + + @objc public var dictionaryValueDidChange: Bool { + return false + } + + @objc(saveWithTransaction:) + public func save(with transaction: YapDatabaseReadWriteTransaction) { + self.originalDictionaryValue = self.dictionaryValue + self.isNewRecord = false } } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 036ff774c..0b94044d2 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -49,15 +49,17 @@ enum _001_InitialSetupMigration: Migration { t.column(.shouldBeVisible, .boolean).notNull() t.column(.isPinned, .boolean).notNull() t.column(.messageDraft, .text) - t.column(.notificationMode, .integer).notNull() + t.column(.notificationMode, .integer) + .notNull() + .defaults(to: SessionThread.NotificationMode.all) t.column(.mutedUntilTimestamp, .double) } try db.create(table: DisappearingMessagesConfiguration.self) { t in - t.column(.id, .text) + t.column(.threadId, .text) .notNull() .primaryKey() - .references(SessionThread.self) + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted t.column(.isEnabled, .boolean) .defaults(to: false) .notNull() @@ -67,9 +69,10 @@ enum _001_InitialSetupMigration: Migration { } try db.create(table: ClosedGroup.self) { t in - t.column(.publicKey, .text) + t.column(.threadId, .text) .notNull() .primaryKey() + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted t.column(.name, .text).notNull() t.column(.formationTimestamp, .double).notNull() } @@ -77,18 +80,161 @@ enum _001_InitialSetupMigration: Migration { try db.create(table: ClosedGroupKeyPair.self) { t in t.column(.publicKey, .text) .notNull() - .indexed() - .references(ClosedGroup.self) + .indexed() // Quicker querying + .references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted t.column(.secretKey, .blob).notNull() t.column(.receivedTimestamp, .double).notNull() } + try db.create(table: OpenGroup.self) { t in + t.column(.threadId, .text) + .notNull() + .primaryKey() + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted + t.column(.server, .text).notNull() + t.column(.room, .text).notNull() + t.column(.publicKey, .text).notNull() + t.column(.name, .text).notNull() + t.column(.groupDescription, .text) + t.column(.imageId, .text) + t.column(.imageData, .blob) + t.column(.userCount, .integer).notNull() + t.column(.infoUpdates, .integer).notNull() + } + + try db.create(table: Capability.self) { t in + t.column(.openGroupId, .text) + .notNull() + .indexed() // Quicker querying + .references(OpenGroup.self, onDelete: .cascade) // Delete if OpenGroup deleted + t.column(.capability, .text).notNull() + t.column(.isMissing, .boolean).notNull() + + t.primaryKey([.openGroupId, .capability]) + } + try db.create(table: GroupMember.self) { t in + // Note: Not adding a "proper" foreign key constraint as this + // table gets used by both 'OpenGroup' and 'ClosedGroup' types t.column(.groupId, .text) .notNull() - .indexed() + .indexed() // Quicker querying t.column(.profileId, .text).notNull() t.column(.role, .integer).notNull() } + + try db.create(table: Interaction.self) { t in + t.column(.id, .integer) + .notNull() + .primaryKey(autoincrement: true) + t.column(.serverHash, .text) + t.column(.threadId, .text) + .notNull() + .indexed() // Quicker querying + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted + t.column(.authorId, .text) + .notNull() + .indexed() // Quicker querying + .references(Profile.self) + + t.column(.variant, .integer).notNull() + t.column(.body, .text) + t.column(.timestampMs, .double) + .notNull() + .indexed() // Quicker querying + t.column(.receivedAtTimestampMs, .double).notNull() + t.column(.expiresInSeconds, .double) + t.column(.expiresStartedAtMs, .double) + + t.column(.openGroupInvitationName, .text) + t.column(.openGroupInvitationUrl, .text) + + t.column(.openGroupServerMessageId, .integer) + .indexed() // Quicker querying + t.column(.openGroupWhisperMods, .boolean) + .notNull() + .defaults(to: false) + t.column(.openGroupWhisperTo, .text) + + // Null is not unique in SQLite which allows us to do this and we do + // a joint constraint with the `threadId` on the off chance there is + // a collision between different hashes on different servers + t.uniqueKey([.threadId, .serverHash]) + + // The `openGroupServerMessageId` is unique on a per-thread basis + t.uniqueKey([.threadId, .openGroupServerMessageId]) + + // Note: The timestamp will be unique on a per-message basis so we + // need to add the below unique constraint to handle cases where + // the `serverHash` and `openGroupServerMessageId` can both be null + // to try and prevent duplicate messages (it's theoretically possible + // to get a collision with this constraint but is astronomically unlikely) + t.uniqueKey([.threadId, .serverHash, .openGroupServerMessageId, .timestampMs]) + } + + try db.create(table: RecipientState.self) { t in + t.column(.interactionId, .integer) + .notNull() + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.recipientId, .text) + .notNull() + .references(Profile.self) + t.column(.state, .integer).notNull() + t.column(.readTimestampMs, .double) + + // We want to ensure that a recipient can only have a single state for + // each interaction + t.uniqueKey([.interactionId, .recipientId]) + } + + try db.create(table: Quote.self) { t in + t.column(.interactionId, .integer) + .notNull() + .primaryKey() + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.authorId, .text) + .notNull() + .references(Profile.self) + t.column(.timestampMs, .double).notNull() + t.column(.body, .text) + } + + try db.create(table: LinkPreview.self) { t in + t.column(.url, .text) + .notNull() + .primaryKey() + t.column(.interactionId, .integer) + .notNull() + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.title, .text) + } + + try db.create(table: Attachment.self) { t in + t.column(.interactionId, .integer) + .notNull() + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.serverId, .text) + t.column(.variant, .integer).notNull() + t.column(.state, .integer).notNull() + t.column(.contentType, .text).notNull() + t.column(.byteCount, .integer) + .notNull() + .defaults(to: 0) + t.column(.creationTimestamp, .double) + t.column(.sourceFilename, .text) + t.column(.downloadUrl, .text) + t.column(.width, .integer) + t.column(.height, .integer) + t.column(.encryptionKey, .blob) + t.column(.digest, .blob) + t.column(.caption, .text) + t.column(.quoteId, .text) + .references(Quote.self, onDelete: .cascade) // Delete if Quote deleted + t.column(.linkPreviewUrl, .text) + .references(LinkPreview.self, onDelete: .cascade) // Delete if LinkPreview deleted + } } } diff --git a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift index a8d699335..fd14227d1 100644 --- a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -15,14 +15,25 @@ enum _002_YDBToGRDBMigration: Migration { var shouldFailMigration: Bool = false var contacts: Set = [] var contactThreadIds: Set = [] + var threads: Set = [] var disappearingMessagesConfiguration: [String: Legacy.DisappearingMessagesConfiguration] = [:] + var closedGroupKeys: [String: (timestamp: TimeInterval, keys: SessionUtilitiesKit.Legacy.KeyPair)] = [:] var closedGroupName: [String: String] = [:] var closedGroupFormation: [String: UInt64] = [:] var closedGroupModel: [String: TSGroupModel] = [:] var closedGroupZombieMemberIds: [String: Set] = [:] + var openGroupInfo: [String: OpenGroupV2] = [:] + var openGroupUserCount: [String: Int] = [:] + var openGroupImage: [String: Data] = [:] + var openGroupLastMessageServerId: [String: Int64] = [:] // Optional + var openGroupLastDeletionServerId: [String: Int64] = [:] // Optional + + var interactions: [String: [TSInteraction]] = [:] + var attachments: [String: TSAttachment] = [:] + var readReceipts: [String: [Double]] = [:] Storage.read { transaction in // Process the Contacts @@ -30,7 +41,8 @@ enum _002_YDBToGRDBMigration: Migration { guard let contact = object as? Legacy.Contact else { return } contacts.insert(contact) } - + + print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - Start") let userClosedGroupPublicKeys: [String] = transaction.allKeys(inCollection: Legacy.closedGroupPublicKeyCollection) // Process the threads @@ -52,6 +64,8 @@ enum _002_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 } @@ -64,15 +78,14 @@ enum _002_YDBToGRDBMigration: Migration { guard let groupIdData: Data = Data(base64Encoded: base64GroupId), let groupId: String = String(data: groupIdData, encoding: .utf8), - let publicKey: String = groupId.split(separator: "!").last.map({ String($0) }), - let formationTimestamp: UInt64 = transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64 + let publicKey: String = groupId.split(separator: "!").last.map({ String($0) }) else { - SNLog("Unable to decode Closed Group during migration") + SNLog("[Migration Error] Unable to decode Closed Group") shouldFailMigration = true return } guard userClosedGroupPublicKeys.contains(publicKey) else { - SNLog("Found unexpected invalid closed group public key during migration") + SNLog("[Migration Error] Found unexpected invalid closed group public key") shouldFailMigration = true return } @@ -81,7 +94,7 @@ enum _002_YDBToGRDBMigration: Migration { closedGroupName[threadId] = groupThread.name(with: transaction) closedGroupModel[threadId] = groupThread.groupModel - closedGroupFormation[threadId] = formationTimestamp + closedGroupFormation[threadId] = ((transaction.object(forKey: publicKey, inCollection: Legacy.closedGroupFormationTimestampCollection) as? UInt64) ?? 0) closedGroupZombieMemberIds[threadId] = transaction.object( forKey: publicKey, inCollection: Legacy.closedGroupZombieMembersCollection @@ -96,11 +109,48 @@ enum _002_YDBToGRDBMigration: Migration { } } else if groupThread.isOpenGroup { + guard let openGroup: OpenGroupV2 = transaction.object(forKey: threadId, inCollection: Legacy.openGroupCollection) as? OpenGroupV2 else { + SNLog("[Migration Error] Unable to find open group info") + shouldFailMigration = true + return + } + 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 + openGroupLastMessageServerId[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupLastMessageServerIDCollection) as? Int64 + openGroupLastDeletionServerId[threadId] = transaction.object(forKey: openGroup.id, inCollection: Legacy.openGroupLastDeletionServerIDCollection) as? Int64 + } + } + print("RAWR [\(Date().timeIntervalSince1970)] - Process threads - End") + + // Process interactions + print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - Start") + transaction.enumerateKeysAndObjects(inCollection: Legacy.interactionCollection) { _, object, _ in + guard let interaction: TSInteraction = object as? TSInteraction else { + SNLog("[Migration Error] Unable to process interaction") + shouldFailMigration = true + return } + interactions[interaction.uniqueThreadId] = (interactions[interaction.uniqueThreadId] ?? []) + .appending(interaction) + } + print("RAWR [\(Date().timeIntervalSince1970)] - Process interactions - End") + + // Process attachments + print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - Start") + transaction.enumerateKeysAndObjects(inCollection: Legacy.attachmentsCollection) { key, object, _ in + guard let attachment: TSAttachment = object as? TSAttachment else { + SNLog("[Migration Error] Unable to process attachment") + shouldFailMigration = true + return + } - + attachments[key] = attachment + } + print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - End") + } } @@ -111,46 +161,49 @@ enum _002_YDBToGRDBMigration: Migration { // MARK: - Insert Contacts - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - - try contacts.forEach { contact in - let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey) - let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID) + try autoreleasepool { + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - // Create the "Profile" for the legacy contact - try Profile( - id: contact.sessionID, - name: (contact.name ?? contact.sessionID), - nickname: contact.nickname, - profilePictureUrl: contact.profilePictureURL, - profilePictureFileName: contact.profilePictureFileName, - profileEncryptionKey: contact.profileEncryptionKey - ).insert(db) - - // Determine if this contact is a "real" contact (don't want to create contacts for - // every user in the new structure but still want profiles for every user) - if - isCurrentUser || - contactThreadIds.contains(contactThreadId) || - contact.isApproved || - contact.didApproveMe || - contact.isBlocked || - contact.hasBeenBlocked { - // Create the contact - // TODO: Closed group admins??? - try Contact( + try contacts.forEach { contact in + let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey) + let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID) + + // Create the "Profile" for the legacy contact + try Profile( id: contact.sessionID, - isTrusted: (isCurrentUser || contact.isTrusted), - isApproved: (isCurrentUser || contact.isApproved), - isBlocked: (!isCurrentUser && contact.isBlocked), - didApproveMe: (isCurrentUser || contact.didApproveMe), - hasBeenBlocked: (!isCurrentUser && (contact.hasBeenBlocked || contact.isBlocked)) + name: (contact.name ?? contact.sessionID), + nickname: contact.nickname, + profilePictureUrl: contact.profilePictureURL, + profilePictureFileName: contact.profilePictureFileName, + profileEncryptionKey: contact.profileEncryptionKey ).insert(db) + + // Determine if this contact is a "real" contact (don't want to create contacts for + // every user in the new structure but still want profiles for every user) + if + isCurrentUser || + contactThreadIds.contains(contactThreadId) || + contact.isApproved || + contact.didApproveMe || + contact.isBlocked || + contact.hasBeenBlocked { + // Create the contact + try Contact( + id: contact.sessionID, + isTrusted: (isCurrentUser || contact.isTrusted), + isApproved: (isCurrentUser || contact.isApproved), + isBlocked: (!isCurrentUser && contact.isBlocked), + didApproveMe: (isCurrentUser || contact.didApproveMe), + hasBeenBlocked: (!isCurrentUser && (contact.hasBeenBlocked || contact.isBlocked)) + ).insert(db) + } } } // MARK: - Insert Threads + print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - Start") + try threads.forEach { thread in guard let legacyThreadId: String = thread.uniqueId else { return } @@ -161,11 +214,17 @@ enum _002_YDBToGRDBMigration: Migration { switch thread { case let groupThread as TSGroupThread: if groupThread.isOpenGroup { - id = legacyThreadId//openGroup.id + 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 } @@ -186,71 +245,262 @@ enum _002_YDBToGRDBMigration: Migration { notificationMode = (thread.isMuted ? .none : .all) } - try SessionThread( - id: id, - variant: variant, - creationDateTimestamp: thread.creationDate.timeIntervalSince1970, - shouldBeVisible: thread.shouldBeVisible, - isPinned: thread.isPinned, - messageDraft: thread.messageDraft, - notificationMode: notificationMode, - mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970 - ).insert(db) - - // Disappearing Messages Configuration - if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[id] { - try DisappearingMessagesConfiguration( + try autoreleasepool { + try SessionThread( id: id, - isEnabled: config.isEnabled, - durationSeconds: TimeInterval(config.durationSeconds) + variant: variant, + creationDateTimestamp: thread.creationDate.timeIntervalSince1970, + shouldBeVisible: thread.shouldBeVisible, + isPinned: thread.isPinned, + messageDraft: thread.messageDraft, + notificationMode: notificationMode, + mutedUntilTimestamp: thread.mutedUntilDate?.timeIntervalSince1970 ).insert(db) + + // Disappearing Messages Configuration + if let config: Legacy.DisappearingMessagesConfiguration = disappearingMessagesConfiguration[id] { + try DisappearingMessagesConfiguration( + threadId: id, + isEnabled: config.isEnabled, + durationSeconds: TimeInterval(config.durationSeconds) + ).insert(db) + } + + // Closed Groups + if (thread as? TSGroupThread)?.isClosedGroup == true { + guard + let keyInfo = closedGroupKeys[legacyThreadId], + let name: String = closedGroupName[legacyThreadId], + let groupModel: TSGroupModel = closedGroupModel[legacyThreadId], + let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId] + else { + SNLog("[Migration Error] Closed group missing required data") + throw GRDBStorageError.migrationFailed + } + + try ClosedGroup( + threadId: id, + name: name, + formationTimestamp: TimeInterval(formationTimestamp) + ).insert(db) + + try ClosedGroupKeyPair( + publicKey: keyInfo.keys.publicKey.toHexString(), + secretKey: keyInfo.keys.privateKey, + receivedTimestamp: keyInfo.timestamp + ).insert(db) + + try groupModel.groupMemberIds.forEach { memberId in + try GroupMember( + groupId: id, + profileId: memberId, + role: .standard + ).insert(db) + } + + try groupModel.groupAdminIds.forEach { adminId in + try GroupMember( + groupId: id, + profileId: adminId, + role: .admin + ).insert(db) + } + + try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in + try GroupMember( + groupId: id, + profileId: zombieId, + role: .zombie + ).insert(db) + } + } + + // Open Groups + if (thread as? TSGroupThread)?.isOpenGroup == true { + guard let openGroup: OpenGroupV2 = openGroupInfo[legacyThreadId] else { + SNLog("[Migration Error] Open group missing required data") + throw GRDBStorageError.migrationFailed + } + + try OpenGroup( + server: openGroup.server, + room: openGroup.room, + publicKey: openGroup.publicKey, + name: openGroup.name, + groupDescription: nil, // TODO: Add with SOGS V4 + imageId: nil, // TODO: Add with SOGS V4 + imageData: openGroupImage[legacyThreadId], + userCount: (openGroupUserCount[legacyThreadId] ?? 0), // Will be updated next poll + infoUpdates: 0 // TODO: Add with SOGS V4 + ).insert(db) + } } - // Closed Groups - if (thread as? TSGroupThread)?.isClosedGroup == true { - guard - let keyInfo = closedGroupKeys[legacyThreadId], - let name: String = closedGroupName[legacyThreadId], - let groupModel: TSGroupModel = closedGroupModel[legacyThreadId], - let formationTimestamp: UInt64 = closedGroupFormation[legacyThreadId] - else { throw GRDBStorageError.migrationFailed } + try autoreleasepool { + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - try ClosedGroup( - publicKey: keyInfo.keys.publicKey.toHexString(), - name: name, - formationTimestamp: TimeInterval(formationTimestamp) - ).insert(db) - - try ClosedGroupKeyPair( - publicKey: keyInfo.keys.publicKey.toHexString(), - secretKey: keyInfo.keys.privateKey, - receivedTimestamp: keyInfo.timestamp - ).insert(db) - - try groupModel.groupMemberIds.forEach { memberId in - try GroupMember( - groupId: id, - profileId: memberId, - role: .standard - ).insert(db) - } - - try groupModel.groupAdminIds.forEach { adminId in - try GroupMember( - groupId: id, - profileId: adminId, - role: .admin - ).insert(db) - } - - try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in - try GroupMember( - groupId: id, - profileId: zombieId, - role: .zombie - ).insert(db) + try interactions[legacyThreadId]? + .sorted(by: { lhs, rhs in lhs.sortId < rhs.sortId }) // Maintain sort order + .forEach { legacyInteraction in + let serverHash: String? + let variant: Interaction.Variant + let authorId: String + let body: String? + let expiresInSeconds: UInt32? + let expiresStartedAtMs: UInt64? + let openGroupInvitationName: String? + let openGroupInvitationUrl: String? + let openGroupServerMessageId: UInt64? + let recipientStateMap: [String: TSOutgoingMessageRecipientState]? + let attachmentIds: [String] + + // Handle the common 'TSMessage' values first + if let legacyMessage: TSMessage = legacyInteraction as? TSMessage { + serverHash = legacyMessage.serverHash + openGroupInvitationName = legacyMessage.openGroupInvitationName + openGroupInvitationUrl = legacyMessage.openGroupInvitationURL + + // The legacy code only considered '!= 0' ids as valid so set those + // values to be null to avoid the unique constraint (it's also more + // correct for the values to be null) + openGroupServerMessageId = (legacyMessage.openGroupServerMessageID == 0 ? + nil : + legacyMessage.openGroupServerMessageID + ) + attachmentIds = try legacyMessage.attachmentIds.map { legacyId in + guard let attachmentId: String = legacyId as? String else { + SNLog("[Migration Error] Unable to process attachment id") + throw GRDBStorageError.migrationFailed + } + + return attachmentId + } + } + else { + serverHash = nil + openGroupInvitationName = nil + openGroupInvitationUrl = nil + openGroupServerMessageId = nil + attachmentIds = [] + } + + // Then handle the behaviours for each message type + switch legacyInteraction { + case let incomingMessage as TSIncomingMessage: + variant = .standardIncoming + authorId = incomingMessage.authorId + body = incomingMessage.body + expiresInSeconds = incomingMessage.expiresInSeconds + expiresStartedAtMs = incomingMessage.expireStartedAt + recipientStateMap = [:] + + + case let outgoingMessage as TSOutgoingMessage: + variant = .standardOutgoing + authorId = currentUserPublicKey + body = outgoingMessage.body + expiresInSeconds = outgoingMessage.expiresInSeconds + expiresStartedAtMs = outgoingMessage.expireStartedAt + recipientStateMap = outgoingMessage.recipientStateMap + + case let infoMessage as TSInfoMessage: + authorId = currentUserPublicKey + body = ((infoMessage.body ?? "").isEmpty ? + infoMessage.customMessage : + infoMessage.body + ) + expiresInSeconds = nil // Info messages don't expire + expiresStartedAtMs = nil // Info messages don't expire + recipientStateMap = [:] + + switch infoMessage.messageType { + case .groupCreated: variant = .infoClosedGroupCreated + case .groupUpdated: variant = .infoClosedGroupUpdated + case .groupCurrentUserLeft: variant = .infoClosedGroupCurrentUserLeft + case .disappearingMessagesUpdate: variant = .infoDisappearingMessagesUpdate + case .messageRequestAccepted: variant = .infoMessageRequestAccepted + case .screenshotNotification: variant = .infoScreenshotNotification + case .mediaSavedNotification: variant = .infoMediaSavedNotification + + @unknown default: + SNLog("[Migration Error] Unsupported info message type") + throw GRDBStorageError.migrationFailed + } + + default: + SNLog("[Migration Error] Unsupported interaction type") + throw GRDBStorageError.migrationFailed + } + + // Insert the data + let interaction = try Interaction( + serverHash: serverHash, + threadId: id, + authorId: authorId, + variant: variant, + body: body, + timestampMs: Double(legacyInteraction.timestamp), + receivedAtTimestampMs: Double(legacyInteraction.receivedAtTimestamp), + expiresInSeconds: expiresInSeconds.map { TimeInterval($0) }, + expiresStartedAtMs: expiresStartedAtMs.map { Double($0) }, + openGroupInvitationName: openGroupInvitationName, + openGroupInvitationUrl: openGroupInvitationUrl, + openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) }, + openGroupWhisperMods: false, // TODO: This + openGroupWhisperTo: nil // TODO: This + ).inserted(db) + + guard let interactionId: Int64 = interaction.id else { + SNLog("[Migration Error] Failed to insert interaction") + throw GRDBStorageError.migrationFailed + } + + try recipientStateMap?.forEach { recipientId, legacyState in + try RecipientState( + interactionId: interactionId, + recipientId: recipientId, + state: { + switch legacyState.state { + case .failed: return .failed + case .sending: return .sending + case .skipped: return .skipped + case .sent: return .sent + @unknown default: throw GRDBStorageError.migrationFailed + } + }(), + readTimestampMs: legacyState.readTimestamp?.doubleValue + ).insert(db) + } + try attachmentIds.forEach { attachmentId in + guard let attachment: TSAttachment = attachments[attachmentId] else { + SNLog("[Migration Error] Unsupported interaction type") + throw GRDBStorageError.migrationFailed + } + try Attachment( + interactionId: interactionId, + serverId: "\(attachment.serverId)", + variant: (attachment.isVoiceMessage ? .voiceMessage : .standard), + state: .pending, // TODO: This + contentType: attachment.contentType, + byteCount: UInt(attachment.byteCount), + creationTimestamp: 0, // TODO: This + sourceFilename: attachment.sourceFilename, + downloadUrl: attachment.downloadURL, + width: 0, // TODO: This attachment.mediaSize, + height: 0, // TODO: This attachment.mediaSize, + encryptionKey: attachment.encryptionKey, + digest: nil, // TODO: This attachment.digest, + caption: attachment.caption, + quoteId: nil, // TODO: THis + linkPreviewUrl: nil // TODO: This + ).insert(db) + } } } } + + print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End") + + print("RAWR Done!!!") } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift new file mode 100644 index 000000000..464555c31 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -0,0 +1,131 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "attachment" } + internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + internal static let quoteForeignKey = ForeignKey([Columns.quoteId], to: [Quote.Columns.interactionId]) + internal static let linkPreviewForeignKey = ForeignKey( + [Columns.linkPreviewUrl], + to: [LinkPreview.Columns.url] + ) + private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + private static let quote = belongsTo(Quote.self, using: quoteForeignKey) + private static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case interactionId + case serverId + case variant + case state + case contentType + case byteCount + case creationTimestamp + case sourceFilename + case downloadUrl + case width + case height + case encryptionKey + case digest + case caption + case quoteId + case linkPreviewUrl + } + + public enum Variant: Int, Codable, DatabaseValueConvertible { + case standard + case voiceMessage + } + + public enum State: Int, Codable, DatabaseValueConvertible { + case pending + case downloading + case downloaded + case uploading + case uploaded + case failed + } + + /// The id for the interaction this attachment belongs to + public let interactionId: Int64 + + /// The id for the attachment returned by the server + /// + /// This will be null for attachments which haven’t completed uploading + /// + /// **Note:** This value is not unique as multiple SOGS could end up having the same file id + public let serverId: String? + + /// The type of this attachment, used to distinguish logic handling + public let variant: Variant + + /// The current state of the attachment + public let state: State + + /// The MIMEType for the attachment + public let contentType: String + + /// The size of the attachment in bytes + /// + /// **Note:** This may be `0` for some legacy attachments + public let byteCount: UInt + + /// Timestamp in seconds since epoch for when this attachment was created + /// + /// **Uploaded:** This will be the timestamp the file finished uploading + /// **Downloaded:** This will be the timestamp the file finished downloading + public let creationTimestamp: TimeInterval? + + /// Represents the "source" filename sent or received in the protos, not the filename on disk + public let sourceFilename: String? + + /// The url the attachment can be downloaded from, this will be `null` for attachments which haven’t yet been uploaded + /// + /// **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 width of the attachment, this will be `null` for non-visual attachment types + public let width: UInt? + + /// The height of the attachment, this will be `null` for non-visual attachment types + public let height: UInt? + + /// The key used to decrypt the attachment + public let encryptionKey: Data? + + /// The computed digest for the attachment (generated from `iv || encrypted data || hmac`) + public let digest: Data? + + /// Caption for the attachment + public let caption: String? + + /// The id for the QuotedMessage if this attachment belongs to one + /// + /// **Note:** If this value is present then this attachment shouldn't be returned as a + /// standard attachment for the interaction + public let quoteId: String? + + /// The id for the LinkPreview if this attachment belongs to one + /// + /// **Note:** If this value is present then this attachment shouldn't be returned as a + /// standard attachment for the interaction + public let linkPreviewUrl: String? + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: Attachment.interaction) + } + + public var quote: QueryInterfaceRequest { + request(for: Attachment.quote) + } + + public var linkPreview: QueryInterfaceRequest { + request(for: Attachment.linkPreview) + } +} diff --git a/SessionMessagingKit/Database/Models/Capability.swift b/SessionMessagingKit/Database/Models/Capability.swift index f4c51e2fe..4b5be2f0b 100644 --- a/SessionMessagingKit/Database/Models/Capability.swift +++ b/SessionMessagingKit/Database/Models/Capability.swift @@ -6,17 +6,23 @@ import SessionUtilitiesKit public struct Capability: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "capability" } + internal static let openGroupForeignKey = ForeignKey([Columns.openGroupId], to: [OpenGroup.Columns.threadId]) + private static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { - case server - case room + case openGroupId case capability case isMissing } - public let server: String - public let room: String + public let openGroupId: String public let capability: String public let isMissing: Bool + + // MARK: - Relationships + + public var openGroup: QueryInterfaceRequest { + request(for: Capability.openGroup) + } } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index cd554a1a7..b39114288 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -6,22 +6,37 @@ import SessionUtilitiesKit public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroup" } - static let keyPairs = hasMany(ClosedGroupKeyPair.self) - static let members = hasMany(GroupMember.self) + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + private static let keyPairs = hasMany( + ClosedGroupKeyPair.self, + using: ClosedGroupKeyPair.closedGroupForeignKey + ) + private static let members = hasMany(GroupMember.self, using: GroupMember.closedGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { - case publicKey + case threadId case name case formationTimestamp } - public var id: String { publicKey } + public var id: String { threadId } // Identifiable + public var publicKey: String { threadId } - public let publicKey: String + /// The id for the thread this closed group belongs to + /// + /// **Note:** This value will always be publicKey for the closed group + public let threadId: String public let name: String public let formationTimestamp: TimeInterval + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: ClosedGroup.thread) + } + public var keyPairs: QueryInterfaceRequest { request(for: ClosedGroup.keyPairs) } @@ -45,4 +60,14 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe request(for: ClosedGroup.members) .filter(GroupMember.Columns.role == GroupMember.Role.admin) } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // Delete all 'GroupMember' records associated with this ClosedGroup (can't + // have a proper ForeignKey constraint as 'GroupMember' is reused for the + // 'OpenGroup' table as well) + try request(for: ClosedGroup.members).deleteAll(db) + return try performDelete(db) + } } diff --git a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift index 95980a19e..b1b5eac3b 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift @@ -6,6 +6,11 @@ import SessionUtilitiesKit public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroupKeyPair" } + internal static let closedGroupForeignKey = ForeignKey( + [Columns.publicKey], + to: [ClosedGroup.Columns.threadId] + ) + private static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -19,4 +24,10 @@ public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, Persis public let publicKey: String public let secretKey: Data public let receivedTimestamp: TimeInterval + + // MARK: - Relationships + + public var closedGroup: QueryInterfaceRequest { + request(for: ClosedGroupKeyPair.closedGroup) + } } diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index d8aac5d0c..2a5d4db4e 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -6,17 +6,27 @@ import SessionUtilitiesKit public struct DisappearingMessagesConfiguration: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "disappearingMessagesConfiguration" } + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { - case id + case threadId case isEnabled case durationSeconds } + + public var id: String { threadId } // Identifiable - public let id: String + public let threadId: String public let isEnabled: Bool public let durationSeconds: TimeInterval + + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: DisappearingMessagesConfiguration.thread) + } } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index 2e2e93345..f5a5aa45a 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -6,6 +6,12 @@ import SessionUtilitiesKit public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "groupMember" } + internal static let openGroupForeignKey = ForeignKey([Columns.groupId], to: [OpenGroup.Columns.threadId]) + internal static let closedGroupForeignKey = ForeignKey([Columns.groupId], to: [ClosedGroup.Columns.threadId]) + internal static let profileForeignKey = ForeignKey([Columns.profileId], to: [Profile.Columns.id]) + private static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) + private static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) + private static let profile = hasOne(Profile.self, using: profileForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -24,4 +30,18 @@ public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRec public let groupId: String public let profileId: String public let role: Role + + // MARK: - Relationships + + public var openGroup: QueryInterfaceRequest { + request(for: GroupMember.openGroup) + } + + public var closedGroup: QueryInterfaceRequest { + request(for: GroupMember.closedGroup) + } + + public var profile: QueryInterfaceRequest { + request(for: GroupMember.profile) + } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift new file mode 100644 index 000000000..eb16d54eb --- /dev/null +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -0,0 +1,211 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "interaction" } + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + private static let profile = hasOne(Profile.self, using: profileForeignKey) + private static let attachments = hasMany(Attachment.self, using: Attachment.interactionForeignKey) + private static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey) + private static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) + private static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + case serverHash + case threadId + case authorId + + case variant + case body + case timestampMs + case receivedAtTimestampMs + + case expiresInSeconds + case expiresStartedAtMs + + case openGroupInvitationName + case openGroupInvitationUrl + + // Open Group specific properties + + case openGroupServerMessageId + case openGroupWhisperMods + case openGroupWhisperTo + } + + public enum Variant: Int, Codable, DatabaseValueConvertible { + case standardIncoming + case standardOutgoing + + // Info Message Types (spacing the values out to make it easier to extend) + case infoClosedGroupCreated = 1000 + case infoClosedGroupUpdated + case infoClosedGroupCurrentUserLeft + + case infoDisappearingMessagesUpdate = 2000 + + case infoScreenshotNotification = 3000 + case infoMediaSavedNotification + + case infoMessageRequestAccepted = 4000 + } + + /// The `id` value is auto incremented by the database, if the `Interaction` hasn't been inserted into + /// the database yet this value will be `nil` + public var id: Int64? = nil + + /// The hash returned by the server when this message was created on the server + /// + /// **Note:** This will only be populated for `standardIncoming`/`standardOutgoing` interactions + /// from either `contact` or `closedGroup` threads + public let serverHash: String? + + /// The id of the thread that this interaction belongs to (used to expose the `thread` variable) + private let threadId: String + + /// The id of the user who sent the message, also used to expose the `profile` variable) + public let authorId: String + + /// The type of interaction + public let variant: Variant + + /// The body of this interaction + public let body: String? + + /// When the interaction was created in milliseconds since epoch + public let timestampMs: Double + + /// When the interaction was received in milliseconds since epoch + public let receivedAtTimestampMs: Double + + /// The number of seconds until this message should expire + public fileprivate(set) var expiresInSeconds: TimeInterval? = nil + + /// The timestamp in milliseconds since 1970 at which this messages expiration timer started counting + /// down (this is stored in order to allow the `expiresInSeconds` value to be updated before a + /// message has expired) + public fileprivate(set) var expiresStartedAtMs: Double? = nil + + /// When sending an Open Group invitation this will be populated with the name of the open group + public let openGroupInvitationName: String? + + /// When sending an Open Group invitation this will be populated with the url of the open group + public let openGroupInvitationUrl: String? + + // Open Group specific properties + + /// The `openGroupServerMessageId` value will only be set for messages from SOGS + public fileprivate(set) var openGroupServerMessageId: Int64? = nil + + /// This flag indicates whether this interaction is a whisper to the mods of an Open Group + public let openGroupWhisperMods: Bool + + /// This value is the id of the user within an Open Group who is the target of this whisper interaction + public let openGroupWhisperTo: String? + + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: Interaction.thread) + } + + public var profile: QueryInterfaceRequest { + request(for: Interaction.profile) + } + + public var attachments: QueryInterfaceRequest { + request(for: Interaction.attachments) + .filter( + Attachment.Columns.quoteId == nil && + Attachment.Columns.linkPreviewUrl == nil + ) + } + + public var quote: QueryInterfaceRequest { + request(for: Interaction.quote) + } + + public var linkPreview: QueryInterfaceRequest { + request(for: Interaction.linkPreview) + } + + public var recipientStates: QueryInterfaceRequest { + request(for: Interaction.recipientStates) + } + + // MARK: - Initialization + + // TODO: Do we actually want these values to have defaults? (check how messages are getting created - convenience constructors??) + init( + serverHash: String?, + threadId: String, + authorId: String, + variant: Variant, + body: String?, + timestampMs: Double, + receivedAtTimestampMs: Double, + expiresInSeconds: TimeInterval?, + expiresStartedAtMs: Double?, + openGroupInvitationName: String?, + openGroupInvitationUrl: String?, + openGroupServerMessageId: Int64?, + openGroupWhisperMods: Bool, + openGroupWhisperTo: String? + ) { + self.serverHash = serverHash + self.threadId = threadId + self.authorId = authorId + self.variant = variant + self.body = body + self.timestampMs = timestampMs + self.receivedAtTimestampMs = receivedAtTimestampMs + self.expiresInSeconds = expiresInSeconds + self.expiresStartedAtMs = expiresStartedAtMs + self.openGroupInvitationName = openGroupInvitationName + self.openGroupInvitationUrl = openGroupInvitationUrl + self.openGroupServerMessageId = openGroupServerMessageId + self.openGroupWhisperMods = openGroupWhisperMods + self.openGroupWhisperTo = openGroupWhisperTo + } + + // MARK: - Custom Database Interaction + + public mutating func didInsert(with rowID: Int64, for column: String?) { + self.id = rowID + } +} + +// MARK: - Convenience + +public extension Interaction { + // MARK: - Variables + + var isExpiringMessage: Bool { + guard variant == .standardIncoming || variant == .standardOutgoing else { return false } + + return (expiresInSeconds ?? 0 > 0) + } + + var openGroupWhisper: Bool { return (openGroupWhisperMods || (openGroupWhisperTo != nil)) } + + // MARK: - Functions + + func with( + expiresInSeconds: TimeInterval? = nil, + expiresStartedAtMs: Double? = nil, + openGroupServerMessageId: Int64? = nil + ) -> Interaction { + var updatedInteraction: Interaction = self + updatedInteraction.expiresInSeconds = (expiresInSeconds ?? updatedInteraction.expiresInSeconds) + updatedInteraction.expiresStartedAtMs = (expiresStartedAtMs ?? updatedInteraction.expiresStartedAtMs) + updatedInteraction.openGroupServerMessageId = (openGroupServerMessageId ?? updatedInteraction.openGroupServerMessageId) + return updatedInteraction + } +} diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift new file mode 100644 index 000000000..15d92651e --- /dev/null +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -0,0 +1,38 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "linkPreview" } + internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + private static let attachment = hasOne(Attachment.self, using: Attachment.linkPreviewForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case url + case interactionId + case title + } + + /// The url for the link preview + public let url: String + + /// The id for the interaction this LinkPreview belongs to + public let interactionId: Int64 + + /// The title for the link + public let title: String? + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: LinkPreview.interaction) + } + + public var attachment: QueryInterfaceRequest { + request(for: LinkPreview.attachment) + } +} diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 221447839..96a28a541 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -6,11 +6,14 @@ import SessionUtilitiesKit public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "openGroup" } - static let capabilities = hasMany(Capability.self) - static let members = hasMany(GroupMember.self) + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + private static let capabilities = hasMany(Capability.self, using: Capability.openGroupForeignKey) + private static let members = hasMany(GroupMember.self, using: GroupMember.openGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId case server case room case publicKey @@ -22,18 +25,47 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco case infoUpdates } - public var id: String { "\(server).\(room)" } - + public var id: String { threadId } // Identifiable + + /// The id for the thread this open group belongs to + /// + /// **Note:** This value will always be `\(server).\(room)` (This needs it’s own column to + /// allow for db joining to the Thread table) + public let threadId: String + + /// The server for the group public let server: String + + /// The specific room on the server for the group public let room: String + + /// The public key for the group public let publicKey: String + + /// The name for the group public let name: String + + /// The description for the group public let groupDescription: String? + + /// The ID with which the image can be retrieved from the server public let imageId: Int? + + /// The image for the group public let imageData: Data? + + /// The number of users in the group public let userCount: Int + + /// Monotonic room information counter that increases each time the room's metadata changes public let infoUpdates: Int + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: OpenGroup.thread) + } + public var capabilities: QueryInterfaceRequest { request(for: OpenGroup.capabilities) } @@ -47,4 +79,40 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco request(for: OpenGroup.members) .filter(GroupMember.Columns.role == GroupMember.Role.admin) } + + // MARK: - Initialization + + init( + server: String, + room: String, + publicKey: String, + name: String, + groupDescription: String?, + imageId: Int?, + imageData: Data?, + userCount: Int, + infoUpdates: Int + ) { + // Always force the server to lowercase + self.threadId = "\(server.lowercased()).\(room)" // TODO: Validate this (doesn't seem to happen in the old code...) + self.server = server.lowercased() + self.room = room + self.publicKey = publicKey + self.name = name + self.groupDescription = groupDescription + self.imageId = imageId + self.imageData = imageData + self.userCount = userCount + self.infoUpdates = infoUpdates + } + + // MARK: - Custom Database Interaction + + public func delete(_ db: Database) throws -> Bool { + // Delete all 'GroupMember' records associated with this OpenGroup (can't + // have a proper ForeignKey constraint as 'GroupMember' is reused for the + // 'ClosedGroup' table as well) + try request(for: OpenGroup.members).deleteAll(db) + return try performDelete(db) + } } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index cdbc7a21c..d88ef045b 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -65,6 +65,9 @@ public struct Profile: Codable, Identifiable, FetchableRecord, PersistableRecord OWSFileSystem.deleteFileIfExists(path) } } + + // Since it's possible this profile is currently being displayed, send notifications + // indicating that it has been updated NotificationCenter.default.post(name: .profileUpdated, object: id) if id == getUserHexEncodedPublicKey(db) { diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift new file mode 100644 index 000000000..e3f8f8ad5 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -0,0 +1,57 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Quote: Codable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "quote" } + internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + internal static let originalInteractionForeignKey = ForeignKey( + [Columns.timestampMs, Columns.authorId], + to: [Interaction.Columns.timestampMs, Interaction.Columns.authorId] + ) + internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) + private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + private static let profile = hasOne(Profile.self, using: profileForeignKey) + private static let attachment = hasOne(Attachment.self, using: Attachment.quoteForeignKey) + private static let quotedInteraction = hasOne(Interaction.self, using: originalInteractionForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case interactionId + case authorId + case timestampMs + case body + } + + /// The id for the interaction this Quote belongs to + public let interactionId: Int64 + + /// The id for the author this Quote belongs to + public let authorId: String + + /// The timestamp in milliseconds since epoch when the quoted interaction was sent + public let timestampMs: Double + + /// The body of the quoted message if the user is quoting a text message or an attachment with a caption + public let body: String? + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: Quote.interaction) + } + + public var profile: QueryInterfaceRequest { + request(for: Quote.profile) + } + + public var attachment: QueryInterfaceRequest { + request(for: Quote.attachment) + } + + public var originalInteraction: QueryInterfaceRequest { + request(for: Quote.quotedInteraction) + } +} diff --git a/SessionMessagingKit/Database/Models/RecipientState.swift b/SessionMessagingKit/Database/Models/RecipientState.swift new file mode 100644 index 000000000..7cd50f9a0 --- /dev/null +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -0,0 +1,55 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct RecipientState: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "recipientState" } + internal static let profileForeignKey = ForeignKey([Columns.recipientId], to: [Profile.Columns.id]) + internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) + private static let profile = hasOne(Profile.self, using: profileForeignKey) + private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case interactionId + case recipientId + case state + case readTimestampMs + } + + public enum State: Int, Codable, DatabaseValueConvertible { + case failed + case sending + case skipped + case sent + } + + /// The id for the interaction this state belongs to + public let interactionId: Int64 + + /// The id for the recipient this state belongs to + public let recipientId: String + + /// The current state for the recipient + public let state: State + + /// When the interaction was read in milliseconds since epoch + /// + /// This value will be null for outgoing messages + /// + /// **Note:** This currently will be set when opening the thread for the first time after receiving this interaction + /// rather than when the interaction actually appears on the screen + public fileprivate(set) var readTimestampMs: Double? = nil // TODO: Add setter + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: RecipientState.interaction) + } + + public var profile: QueryInterfaceRequest { + request(for: RecipientState.profile) + } +} diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index efeabb7d8..dbb20be31 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -6,9 +6,13 @@ import SessionUtilitiesKit public struct SessionThread: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "thread" } - static let disappearingMessagesConfiguration = hasOne(DisappearingMessagesConfiguration.self) - static let closedGroup = hasOne(ClosedGroup.self) - static let openGroup = hasOne(OpenGroup.self) + private static let closedGroup = hasOne(ClosedGroup.self, using: ClosedGroup.threadForeignKey) + private static let openGroup = hasOne(OpenGroup.self, using: OpenGroup.threadForeignKey) + private static let disappearingMessagesConfiguration = hasOne( + DisappearingMessagesConfiguration.self, + using: DisappearingMessagesConfiguration.threadForeignKey + ) + private static let interactions = hasMany(Interaction.self, using: Interaction.threadForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -29,8 +33,8 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable } public enum NotificationMode: Int, Codable, DatabaseValueConvertible { - case all case none + case all case mentionsOnly // Only applicable to group threads } @@ -43,11 +47,7 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable public let notificationMode: NotificationMode public let mutedUntilTimestamp: TimeInterval? - public var disappearingMessagesConfiguration: QueryInterfaceRequest { - request(for: SessionThread.disappearingMessagesConfiguration) - } - -// public var lastInteraction + // MARK: - Relationships public var closedGroup: QueryInterfaceRequest { request(for: SessionThread.closedGroup) @@ -56,4 +56,13 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable public var openGroup: QueryInterfaceRequest { request(for: SessionThread.openGroup) } + + public var disappearingMessagesConfiguration: QueryInterfaceRequest { + request(for: SessionThread.disappearingMessagesConfiguration) + } + + public var interactions: QueryInterfaceRequest { + request(for: SessionThread.interactions) + } + } diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h index b61bada56..5a671c9a8 100644 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h +++ b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h @@ -73,6 +73,8 @@ typedef NS_ENUM(NSInteger, TSGroupMetaMessage) { @interface TSOutgoingMessage : TSMessage +@property (atomic, nullable) NSDictionary *recipientStateMap; + - (instancetype)initMessageWithTimestamp:(uint64_t)timestamp inThread:(nullable TSThread *)thread messageBody:(nullable NSString *)body diff --git a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m index f2985104d..aaf1f33d6 100644 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m +++ b/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m @@ -79,7 +79,6 @@ NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientSt @property (atomic) NSString *customMessage; @property (atomic) NSString *mostRecentFailureText; @property (atomic) TSGroupMetaMessage groupMetaMessage; -@property (atomic, nullable) NSDictionary *recipientStateMap; @end diff --git a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift index 416f5d65d..71bebacf4 100644 --- a/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -74,27 +74,29 @@ enum _002_YDBToGRDBMigration: Migration { // Insert the data into GRDB - db[.lastSnodePoolRefreshDate] = lastSnodePoolRefreshDate - - try snodeResult.forEach { legacySnode in - try Snode( - address: legacySnode.address, - port: legacySnode.port, - ed25519PublicKey: legacySnode.publicKeySet.ed25519Key, - x25519PublicKey: legacySnode.publicKeySet.x25519Key - ).insert(db) - } - - try snodeSetResult.forEach { key, legacySnodeSet in - try legacySnodeSet.enumerated().forEach { nodeIndex, legacySnode in - // Note: In this case the 'nodeIndex' is irrelivant - try SnodeSet( - key: key, - nodeIndex: nodeIndex, + try autoreleasepool { + db[.lastSnodePoolRefreshDate] = lastSnodePoolRefreshDate + + try snodeResult.forEach { legacySnode in + try Snode( address: legacySnode.address, - port: legacySnode.port + port: legacySnode.port, + ed25519PublicKey: legacySnode.publicKeySet.ed25519Key, + x25519PublicKey: legacySnode.publicKeySet.x25519Key ).insert(db) } + + try snodeSetResult.forEach { key, legacySnodeSet in + try legacySnodeSet.enumerated().forEach { nodeIndex, legacySnode in + // Note: In this case the 'nodeIndex' is irrelivant + try SnodeSet( + key: key, + nodeIndex: nodeIndex, + address: legacySnode.address, + port: legacySnode.port + ).insert(db) + } + } } // MARK: - Received Messages & Last Message Hash @@ -121,22 +123,24 @@ enum _002_YDBToGRDBMigration: Migration { } } - try receivedMessageResults.forEach { key, hashes in - try hashes.forEach { hash in + try autoreleasepool { + try receivedMessageResults.forEach { key, hashes in + try hashes.forEach { hash in + try SnodeReceivedMessageInfo( + key: key, + hash: hash, + expirationDateMs: 0 + ).insert(db) + } + } + + try lastMessageResults.forEach { key, data in try SnodeReceivedMessageInfo( key: key, - hash: hash, - expirationDateMs: 0 + hash: data.hash, + expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0) ).insert(db) } } - - try lastMessageResults.forEach { key, data in - try SnodeReceivedMessageInfo( - key: key, - hash: data.hash, - expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0) - ).insert(db) - } } } diff --git a/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift index b82883f98..61065617b 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -65,31 +65,32 @@ enum _002_YDBToGRDBMigration: Migration { throw GRDBStorageError.migrationFailed } - - // Insert the data into GRDB - try Identity( - variant: .seed, - data: Data(hex: seedHexString) - ).insert(db) - - try Identity( - variant: .ed25519SecretKey, - data: Data(hex: userEd25519SecretKeyHexString) - ).insert(db) - - try Identity( - variant: .ed25519PublicKey, - data: Data(hex: userEd25519PublicKeyHexString) - ).insert(db) - - try Identity( - variant: .x25519PrivateKey, - data: userX25519KeyPair.privateKey - ).insert(db) - - try Identity( - variant: .x25519PublicKey, - data: userX25519KeyPair.publicKey - ).insert(db) + try autoreleasepool { + // Insert the data into GRDB + try Identity( + variant: .seed, + data: Data(hex: seedHexString) + ).insert(db) + + try Identity( + variant: .ed25519SecretKey, + data: Data(hex: userEd25519SecretKeyHexString) + ).insert(db) + + try Identity( + variant: .ed25519PublicKey, + data: Data(hex: userEd25519PublicKeyHexString) + ).insert(db) + + try Identity( + variant: .x25519PrivateKey, + data: userX25519KeyPair.privateKey + ).insert(db) + + try Identity( + variant: .x25519PublicKey, + data: userX25519KeyPair.publicKey + ).insert(db) + } } } diff --git a/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift index db2157576..67ce68016 100644 --- a/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift +++ b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift @@ -20,6 +20,10 @@ public class TypedTableDefinition where T: TableRecord, T: ColumnExpressible definition.primaryKey(columns.map { $0.name }, onConflict: onConflict) } + public func uniqueKey(_ columns: [T.Columns], onConflict: Database.ConflictResolution? = nil) { + definition.uniqueKey(columns.map { $0.name }, onConflict: onConflict) + } + public func foreignKey( _ columns: [T.Columns], references table: Other.Type, diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index 362a47f17..f8382dacc 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -16,6 +16,11 @@ public final class Configuration : NSObject { maxFileSize: UInt(Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier) ) + SNMessagingKit.configure(storage: Storage.shared) + SNSnodeKit.configure() + } + + @objc public static func performDatabaseSetup() { if !isSetup { isSetup = true @@ -30,8 +35,5 @@ public final class Configuration : NSObject { ] ) } - - SNMessagingKit.configure(storage: Storage.shared) - SNSnodeKit.configure() } } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.m b/SignalUtilitiesKit/Utilities/AppSetup.m index 0cdf5a0f1..b93ecf3c4 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ b/SignalUtilitiesKit/Utilities/AppSetup.m @@ -100,6 +100,9 @@ NS_ASSUME_NONNULL_BEGIN }]; }); }]; + + // Must happen after the performUpdateCheck above to ensure all legacy database migrations have run + [SNConfiguration performDatabaseSetup]; }); }