From 4ee4b3ffb34a02e98ec9485149ea1e69961fcd29 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 4 Apr 2022 13:18:14 +1000 Subject: [PATCH] Started adding migration logic for contacts Updated the getUserHexEncodedPublicKey to take an optional db value so we can retrieve it during the initial migration --- Session.xcodeproj/project.pbxproj | 20 +- Session/Utilities/MockDataGenerator.swift | 136 +++++++++--- SessionMessagingKit/Contacts/Contact.swift | 127 ----------- .../LegacyDatabase/SMKLegacyModels.swift | 209 +++++++++++++++++- .../_001_InitialSetupMigration.swift | 44 ++-- .../Migrations/_002_YDBToGRDBMigration.swift | 57 +++++ .../Database/Models/Contact.swift | 44 ++++ .../Database/Models/Profile.swift | 174 +++++++++++++++ .../LegacyDatabase/SSKLegacyModels.swift | 2 +- .../Database/Models/Identity.swift | 12 +- SessionUtilitiesKit/General/General.swift | 5 +- 11 files changed, 652 insertions(+), 178 deletions(-) delete mode 100644 SessionMessagingKit/Contacts/Contact.swift create mode 100644 SessionMessagingKit/Database/Models/Contact.swift create mode 100644 SessionMessagingKit/Database/Models/Profile.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 7909b4ffe..1a46ff9a3 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -231,7 +231,6 @@ B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */; }; B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */; }; B8AF4BB426A5204600583500 /* SendSeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8AF4BB326A5204600583500 /* SendSeedModal.swift */; }; - B8B32021258B1A650020074B /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32020258B1A650020074B /* Contact.swift */; }; B8B32033258B235D0020074B /* Storage+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32032258B235D0020074B /* Storage+Contacts.swift */; }; B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32044258C117C0020074B /* ContactsMigration.swift */; }; B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B320B6258C30D70020074B /* HTMLMetadata.swift */; }; @@ -749,6 +748,8 @@ FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E7134E251C867C009649BB /* Sodium+Conversion.swift */; }; FD09796927F6BEA700936362 /* SwarmSnode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796827F6BEA700936362 /* SwarmSnode.swift */; }; FD09796B27F6C67500936362 /* Failable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796A27F6C67500936362 /* Failable.swift */; }; + FD09796E27FA6D0000936362 /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796D27FA6D0000936362 /* Contact.swift */; }; + FD09797027FA6FF300936362 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09796F27FA6FF300936362 /* Profile.swift */; }; 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 */; }; @@ -1239,7 +1240,6 @@ B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Trimming.swift"; sourceTree = ""; }; B8AF4BB326A5204600583500 /* SendSeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendSeedModal.swift; sourceTree = ""; }; - B8B32020258B1A650020074B /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; B8B32032258B235D0020074B /* Storage+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Contacts.swift"; sourceTree = ""; }; B8B32044258C117C0020074B /* ContactsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsMigration.swift; sourceTree = ""; }; B8B320B6258C30D70020074B /* HTMLMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLMetadata.swift; sourceTree = ""; }; @@ -1793,6 +1793,8 @@ FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; FD09796827F6BEA700936362 /* SwarmSnode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwarmSnode.swift; sourceTree = ""; }; FD09796A27F6C67500936362 /* Failable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Failable.swift; sourceTree = ""; }; + FD09796D27FA6D0000936362 /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; + FD09796F27FA6FF300936362 /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = ""; }; 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 = ""; }; @@ -2406,7 +2408,6 @@ B8B3201F258B1A540020074B /* Contacts */ = { isa = PBXGroup; children = ( - B8B32020258B1A650020074B /* Contact.swift */, ); path = Contacts; sourceTree = ""; @@ -2687,6 +2688,7 @@ children = ( FD17D79A27F40ADA00122BE0 /* LegacyDatabase */, FD17D79427F3E03300122BE0 /* Migrations */, + FD09796C27FA6C8B00936362 /* Models */, B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */, C33FDAEA255A580500E217F9 /* OWSBackupFragment.h */, C33FDB07255A580700E217F9 /* OWSBackupFragment.m */, @@ -3604,6 +3606,15 @@ path = Utilities; sourceTree = ""; }; + FD09796C27FA6C8B00936362 /* Models */ = { + isa = PBXGroup; + children = ( + FD09796D27FA6D0000936362 /* Contact.swift */, + FD09796F27FA6FF300936362 /* Profile.swift */, + ); + path = Models; + sourceTree = ""; + }; FD17D79427F3E03300122BE0 /* Migrations */ = { isa = PBXGroup; children = ( @@ -4854,7 +4865,6 @@ C3D9E3BF25676AD70040E4F3 /* TSAttachmentStream.m in Sources */, C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */, C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */, - B8B32021258B1A650020074B /* Contact.swift in Sources */, C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */, C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */, 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */, @@ -4929,6 +4939,7 @@ C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */, B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */, + FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, C32C5B1C256DC19D003C73A2 /* TSQuotedMessage.m in Sources */, @@ -4954,6 +4965,7 @@ C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, C3A3A13C256E1B27004D228D /* OWSMediaGalleryFinder.m in Sources */, + FD09797027FA6FF300936362 /* Profile.swift in Sources */, C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */, C32C5FD6256E0346003C73A2 /* Notification+Thread.swift in Sources */, diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 8313d807a..67adf0a60 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -66,6 +66,11 @@ enum MockDataGenerator { } } + // MARK: - Generation + + static var printProgress: Bool = true + static var hasStartedGenerationThisRun: Bool = false + static func generateMockData() { // Don't re-generate the mock data if it already exists var existingMockDataThread: TSContactThread? @@ -74,29 +79,43 @@ enum MockDataGenerator { existingMockDataThread = TSContactThread.getWithContactSessionID("MockDatabaseThread", transaction: transaction) } - guard existingMockDataThread == nil else { return } + guard !hasStartedGenerationThisRun && existingMockDataThread == nil else { + hasStartedGenerationThisRun = true + return + } /// The mock data generation is quite slow, there are 3 parts which take a decent amount of time (deleting the account afterwards will also take a long time): /// Generating the threads & content - ~3s per 100 /// Writing to the database - ~10s per 1000 /// Updating the UI - ~10s per 1000 - let dmThreadCount: Int = 100 - let closedGroupThreadCount: Int = 0 - let openGroupThreadCount: Int = 0 - let maxMessagesPerThread: Int = 50 + let dmThreadCount: Int = 1000 + let closedGroupThreadCount: Int = 50 + let openGroupThreadCount: Int = 20 + let messageRangePerThread: [ClosedRange] = [(0...500)] let dmRandomSeed: Int = 1111 let cgRandomSeed: Int = 2222 let ogRandomSeed: Int = 3333 + let logProgress: (String, String) -> () = { title, event in + guard printProgress else { return } + + print("[MockDataGenerator] (\(Date().timeIntervalSince1970)) \(title) - \(event)") + } + + 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 } + 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") _ = TSContactThread.getOrCreateThread(withContactSessionID: "MockDatabaseThread", transaction: transaction) // 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() @@ -104,40 +123,46 @@ enum MockDataGenerator { var dmThreadRandomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: dmRandomSeed) (0.. String? { - if let nickname = nickname { return nickname } - switch context { - case .regular: return name - case .openGroup: - // In open groups, where it's more likely that multiple users have the same name, we display a bit of the Session ID after - // a user's display name for added context. - guard let name = name else { return nil } - let endIndex = sessionID.endIndex - let cutoffIndex = sessionID.index(endIndex, offsetBy: -8) - return "\(name) (...\(sessionID[cutoffIndex.. Bool { - guard let other = other as? Contact else { return false } - return sessionID == other.sessionID - } - - // MARK: Hashing - override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) - return sessionID.hash - } - - // MARK: Description - override public var description: String { - nickname ?? name ?? sessionID - } - - // MARK: Convenience - @objc(contextForThread:) - public static func context(for thread: TSThread) -> Context { - return ((thread as? TSGroupThread)?.isOpenGroup == true) ? .openGroup : .regular - } -} diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift index 79e14cd58..459a8470d 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacyModels.swift @@ -2,5 +2,212 @@ import Foundation -enum Legacy { +public enum Legacy { + // MARK: - Collections and Keys + + internal static let contactThreadPrefix = "c" + internal static let threadCollection = "TSThread" + internal static let contactCollection = "LokiContactCollection" + + // MARK: - Types + + public typealias Contact = _LegacyContact + + @objc(SNProfile) + public class Profile: NSObject, NSCoding { + public var displayName: String? + public var profileKey: Data? + public var profilePictureURL: String? + + internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) { + self.displayName = displayName + self.profileKey = profileKey + self.profilePictureURL = profilePictureURL + } + + public required init?(coder: NSCoder) { + if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName } + if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey } + if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL } + } + + public func encode(with coder: NSCoder) { + coder.encode(displayName, forKey: "displayName") + coder.encode(profileKey, forKey: "profileKey") + coder.encode(profilePictureURL, forKey: "profilePictureURL") + } + + public static func fromProto(_ proto: SNProtoDataMessage) -> Profile? { + guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } + let profileKey = proto.profileKey + let profilePictureURL = profileProto.profilePicture + if let profileKey = profileKey, let profilePictureURL = profilePictureURL { + return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL) + } else { + return Profile(displayName: displayName) + } + } + + public func toProto() -> SNProtoDataMessage? { + guard let displayName = displayName else { + SNLog("Couldn't construct profile proto from: \(self).") + return nil + } + let dataMessageProto = SNProtoDataMessage.builder() + let profileProto = SNProtoDataMessageLokiProfile.builder() + profileProto.setDisplayName(displayName) + if let profileKey = profileKey, let profilePictureURL = profilePictureURL { + dataMessageProto.setProfileKey(profileKey) + profileProto.setProfilePicture(profilePictureURL) + } + do { + dataMessageProto.setProfile(try profileProto.build()) + return try dataMessageProto.build() + } catch { + SNLog("Couldn't construct profile proto from: \(self).") + return nil + } + } + + // MARK: Description + public override var description: String { + """ + Profile( + displayName: \(displayName ?? "null"), + profileKey: \(profileKey?.description ?? "null"), + profilePictureURL: \(profilePictureURL ?? "null") + ) + """ + } + } +} + +// Note: Looks like Swift doesn't expose nested types well (in the `-Swift` header this was +// appearing with `SWIFT_CLASS_NAME("Contact")` which conflicts with the new type and has a +// different structure) as a result we cannot nest this cleanly +@objc(SNContact) +public class _LegacyContact: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility + @objc public let sessionID: String + /// The URL from which to fetch the contact's profile picture. + @objc public var profilePictureURL: String? + /// The file name of the contact's profile picture on local storage. + @objc public var profilePictureFileName: String? + /// The key with which the profile is encrypted. + @objc public var profileEncryptionKey: OWSAES256Key? + /// The ID of the thread associated with this contact. + @objc public var threadID: String? + /// This flag is used to determine whether we should auto-download files sent by this contact. + @objc public var isTrusted = false + /// This flag is used to determine whether message requests from this contact are approved + @objc public var isApproved = false + /// This flag is used to determine whether message requests from this contact are blocked + @objc public var isBlocked = false { + didSet { + if isBlocked { + hasBeenBlocked = true + } + } + } + /// This flag is used to determine whether this contact has approved the current users message request + @objc public var didApproveMe = false + /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) + @objc public var hasBeenBlocked = false + + // MARK: Name + /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message). + @objc public var name: String? + /// The contact's nickname, if the user set one. + @objc public var nickname: String? + /// The name to display in the UI. For local use only. + @objc public func displayName(for context: Context) -> String? { + if let nickname = nickname { return nickname } + switch context { + case .regular: return name + case .openGroup: + // In open groups, where it's more likely that multiple users have the same name, we display a bit of the Session ID after + // a user's display name for added context. + guard let name = name else { return nil } + let endIndex = sessionID.endIndex + let cutoffIndex = sessionID.index(endIndex, offsetBy: -8) + return "\(name) (...\(sessionID[cutoffIndex.. Bool { + guard let other = other as? _LegacyContact else { return false } + return sessionID == other.sessionID + } + + // MARK: Hashing + override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:) + return sessionID.hash + } + + // MARK: Description + override public var description: String { + nickname ?? name ?? sessionID + } + + // MARK: Convenience + @objc(contextForThread:) + public static func context(for thread: TSThread) -> Context { + return ((thread as? TSGroupThread)?.isOpenGroup == true) ? .openGroup : .regular + } } diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 0c5de884d..80e43bf44 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -4,26 +4,40 @@ import Foundation import GRDB import SessionUtilitiesKit -// TODO: Remove/Move these -struct Place: Codable, FetchableRecord, PersistableRecord, ColumnExpressible { - static var databaseTableName: String { "place" } - - public enum Columns: String, CodingKey, ColumnExpression { - case id - case name - } - - let id: String - let name: String -} - enum _001_InitialSetupMigration: Migration { static let identifier: String = "initialSetup" static func migrate(_ db: Database) throws { - try db.create(table: Place.self) { t in - t.column(.id, .text).notNull().primaryKey() + try db.create(table: Contact.self) { t in + t.column(.id, .text) + .notNull() + .primaryKey() + t.column(.isTrusted, .boolean) + .notNull() + .defaults(to: false) + t.column(.isApproved, .boolean) + .notNull() + .defaults(to: false) + t.column(.isBlocked, .boolean) + .notNull() + .defaults(to: false) + t.column(.didApproveMe, .boolean) + .notNull() + .defaults(to: false) + t.column(.hasBeenBlocked, .boolean) + .notNull() + .defaults(to: false) + } + + try db.create(table: Profile.self) { t in + t.column(.id, .text) + .notNull() + .primaryKey() t.column(.name, .text).notNull() + t.column(.nickname, .text) + t.column(.profilePictureUrl, .text) + t.column(.profilePictureFileName, .text) + t.column(.profileEncryptionKey, .blob) } } } diff --git a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift index 3ccc6a5cf..9559a38d6 100644 --- a/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_002_YDBToGRDBMigration.swift @@ -7,8 +7,65 @@ import SessionUtilitiesKit enum _002_YDBToGRDBMigration: Migration { static let identifier: String = "YDBToGRDBMigration" + // TODO: Autorelease pool???. static func migrate(_ db: Database) throws { + // MARK: - Contacts + var contacts: Set = [] + var contactThreadIds: Set = [] + Storage.read { transaction in + // Process the Contacts + transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in + guard let contact = object as? Legacy.Contact else { return } + contacts.insert(contact) + } + + // Process the contact threads (only want to create "real" contacts in the new structure) + transaction.enumerateKeys(inCollection: Legacy.threadCollection) { key, _ in + guard key.starts(with: Legacy.contactThreadPrefix) else { return } + contactThreadIds.insert(key) + } + } + + // Insert the data into GRDB + + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + + try contacts.forEach { contact in + let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey) + let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID) + + // Determine if this contact is a "real" contact + if + // TODO: Thread.shouldBeVisible??? + isCurrentUser || + contactThreadIds.contains(contactThreadId) || + contact.isApproved || + contact.didApproveMe || + contact.isBlocked || + contact.hasBeenBlocked { + // Create the contact + // TODO: Closed group admins??? + 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) + } + + // 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) + } } } diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift new file mode 100644 index 000000000..9294d5c23 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -0,0 +1,44 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Contact: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "contact" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + + case isTrusted + case isApproved + case isBlocked + case didApproveMe + case hasBeenBlocked + } + + /// The id for the contact (Note: This could be a sessionId, a blindedId or some future variant) + public let id: String + + /// This flag is used to determine whether we should auto-download files sent by this contact. + public var isTrusted = false + + /// This flag is used to determine whether message requests from this contact are approved + public var isApproved = false + + /// This flag is used to determine whether message requests from this contact are blocked + public var isBlocked = false { + didSet { + if isBlocked { + hasBeenBlocked = true + } + } + } + + /// This flag is used to determine whether this contact has approved the current users message request + public var didApproveMe = false + + /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) + public var hasBeenBlocked = false +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift new file mode 100644 index 000000000..ad743862d --- /dev/null +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -0,0 +1,174 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct Profile: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible { + public static var databaseTableName: String { "profile" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + + case name = "displayName" + case nickname + + case profilePictureUrl = "profilePictureURL" + case profilePictureFileName + case profileEncryptionKey + } + + /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) + public let id: String + + /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message). + public var name: String + + /// A custom name for the profile set by the current user + public var nickname: String? + + /// The URL from which to fetch the contact's profile picture. + public var profilePictureUrl: String? + + /// The file name of the contact's profile picture on local storage. + public var profilePictureFileName: String? + + /// The key with which the profile is encrypted. + public var profileEncryptionKey: OWSAES256Key? + + // MARK: - Description + + public var description: String { + """ + Profile( + displayName: \(name), + profileKey: \(profileEncryptionKey?.keyData.description ?? "null"), + profilePictureURL: \(profilePictureUrl ?? "null") + ) + """ + } +} + +// MARK: - Codable + +public extension Profile { + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + var profileKey: OWSAES256Key? + var profilePictureUrl: String? + + // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid + if + let profileKeyData: Data = try? container.decode(Data.self, forKey: .profileEncryptionKey), + let profilePictureUrlValue: String = try? container.decode(String.self, forKey: .profilePictureUrl) + { + guard let validProfileKey: OWSAES256Key = OWSAES256Key(data: profileKeyData) else { + owsFailDebug("Failed to make profile key for key data") + throw GRDBStorageError.decodingFailed + } + + profileKey = validProfileKey + profilePictureUrl = profilePictureUrlValue + } + + self = Profile( + id: try container.decode(String.self, forKey: .id), + name: try container.decode(String.self, forKey: .name), + nickname: try? container.decode(String.self, forKey: .nickname), + profilePictureUrl: profilePictureUrl, + profilePictureFileName: try? container.decode(String.self, forKey: .profilePictureFileName), + profileEncryptionKey: profileKey + ) + } + + func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(nickname, forKey: .nickname) + try container.encode(profilePictureUrl, forKey: .profilePictureUrl) + try container.encode(profilePictureFileName, forKey: .profilePictureFileName) + try container.encode(profileEncryptionKey?.keyData, forKey: .profileEncryptionKey) + } +} + +// MARK: - Protobuf + +public extension Profile { + static func fromProto(_ proto: SNProtoDataMessage, id: String) -> Profile? { + guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil } + + var profileKey: OWSAES256Key? + var profilePictureUrl: String? + + // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid + if let profileKeyData: Data = proto.profileKey, profileProto.profilePicture != nil { + guard let validProfileKey: OWSAES256Key = OWSAES256Key(data: profileKeyData) else { + owsFailDebug("Failed to make profile key for key data") + return nil + } + + profileKey = validProfileKey + profilePictureUrl = profileProto.profilePicture + } + + return Profile( + id: id, + name: displayName, + nickname: nil, + profilePictureUrl: profilePictureUrl, + profilePictureFileName: nil, + profileEncryptionKey: profileKey + ) + } + + func toProto() -> SNProtoDataMessage? { + let dataMessageProto = SNProtoDataMessage.builder() + let profileProto = SNProtoDataMessageLokiProfile.builder() + profileProto.setDisplayName(name) + + if let profileKey: OWSAES256Key = profileEncryptionKey, let profilePictureUrl: String = profilePictureUrl { + dataMessageProto.setProfileKey(profileKey.keyData) + profileProto.setProfilePicture(profilePictureUrl) + } + + do { + dataMessageProto.setProfile(try profileProto.build()) + return try dataMessageProto.build() + } + catch { + SNLog("Couldn't construct profile proto from: \(self).") + return nil + } + } +} + +// MARK: - Convenience + +public extension Profile { + // MARK: - Context + + enum Context: Int { + case regular + case openGroup + } + + /// The name to display in the UI. For local use only. + func displayName(for context: Context) -> String? { + if let nickname: String = nickname { return nickname } + + switch context { + case .regular: return name + + case .openGroup: + // In open groups, where it's more likely that multiple users have the same name, we display a bit of the Session ID after + // a user's display name for added context. + let endIndex = id.endIndex + let cutoffIndex = id.index(endIndex, offsetBy: -8) + return "\(name) (...\(id[cutoffIndex.. ECKeyPair? { - return GRDBStorage.shared.read { db -> ECKeyPair? in + static func fetchUserKeyPair(_ db: Database? = nil) -> ECKeyPair? { + let fetchKeys: (Database) -> ECKeyPair? = { db in guard let publicKey: Identity = try? Identity.fetchOne(db, id: .x25519PublicKey), let privateKey: Identity = try? Identity.fetchOne(db, id: .x25519PrivateKey) @@ -83,6 +83,14 @@ public extension Identity { privateKeyData: privateKey.data ) } + + if let db: Database = db { + return fetchKeys(db) + } + + return GRDBStorage.shared.read { db -> ECKeyPair? in + return fetchKeys(db) + } } static func fetchUserEd25519KeyPair() -> Box.KeyPair? { diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index d25e5b774..9a67b6675 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Curve25519Kit public enum General { @@ -20,10 +21,10 @@ public class GeneralUtilities: NSObject { } } -public func getUserHexEncodedPublicKey() -> String { +public func getUserHexEncodedPublicKey(_ db: Database? = nil) -> String { if let cachedKey: String = General.Cache.cachedEncodedPublicKey.wrappedValue { return cachedKey } - if let keyPair: ECKeyPair = Identity.fetchUserKeyPair() { // Can be nil under some circumstances + if let keyPair: ECKeyPair = Identity.fetchUserKeyPair(db) { // Can be nil under some circumstances General.Cache.cachedEncodedPublicKey.mutate { $0 = keyPair.hexEncodedPublicKey } return keyPair.hexEncodedPublicKey }