// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // // stringlint:disable import Foundation import CryptoKit import GRDB import SessionUtil import SessionUtilitiesKit /// This migration makes the neccessary changes to support the updated user config syncing system enum _013_SessionUtilChanges: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "SessionUtilChanges" static let needsConfigSync: Bool = true static let minExpectedRunDuration: TimeInterval = 0.4 static let fetchedTables: [(TableRecord & FetchableRecord).Type] = [ GroupMember.self, ClosedGroupKeyPair.self, SessionThread.self ] static let createdOrAlteredTables: [(TableRecord & FetchableRecord).Type] = [ SessionThread.self, Profile.self, GroupMember.self, ClosedGroupKeyPair.self, ConfigDump.self ] static func migrate(_ db: Database) throws { // Add `markedAsUnread` to the thread table try db.alter(table: SessionThread.self) { t in t.add(.markedAsUnread, .boolean) t.add(.pinnedPriority, .integer) } // Add `lastNameUpdate` and `lastProfilePictureUpdate` columns to the profile table try db.alter(table: Profile.self) { t in t.add(.lastNameUpdate, .integer).defaults(to: 0) t.add(.lastProfilePictureUpdate, .integer).defaults(to: 0) } // SQLite doesn't support adding a new primary key after creation so we need to create a new table with // the setup we want, copy data from the old table over, drop the old table and rename the new table struct TmpGroupMember: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible { static var databaseTableName: String { "tmpGroupMember" } public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case groupId case profileId case role case isHidden } public let groupId: String public let profileId: String public let role: GroupMember.Role public let isHidden: Bool } try db.create(table: TmpGroupMember.self) { t in // Note: Since we don't know whether this will be stored against a 'ClosedGroup' or // an 'OpenGroup' we add the foreign key constraint against the thread itself (which // shares the same 'id' as the 'groupId') so we can cascade delete automatically t.column(.groupId, .text) .notNull() .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted t.column(.profileId, .text) .notNull() t.column(.role, .integer).notNull() t.column(.isHidden, .boolean) .notNull() .defaults(to: false) t.primaryKey([.groupId, .profileId, .role]) } // Retrieve the non-duplicate group member entries from the old table let nonDuplicateGroupMembers: [TmpGroupMember] = try GroupMember .select(.groupId, .profileId, .role, .isHidden) .group(GroupMember.Columns.groupId, GroupMember.Columns.profileId, GroupMember.Columns.role) .asRequest(of: TmpGroupMember.self) .fetchAll(db) // Insert into the new table, drop the old table and rename the new table to be the old one try nonDuplicateGroupMembers.forEach { try $0.save(db) } try db.drop(table: GroupMember.self) try db.rename(table: TmpGroupMember.databaseTableName, to: GroupMember.databaseTableName) // Need to create the indexes separately from creating 'TmpGroupMember' to ensure they // have the correct names try db.createIndex(on: GroupMember.self, columns: [.groupId]) try db.createIndex(on: GroupMember.self, columns: [.profileId]) // SQLite doesn't support removing unique constraints so we need to create a new table with // the setup we want, copy data from the old table over, drop the old table and rename the new table struct TmpClosedGroupKeyPair: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible { static var databaseTableName: String { "tmpClosedGroupKeyPair" } public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case threadId case publicKey case secretKey case receivedTimestamp case threadKeyPairHash } public let threadId: String public let publicKey: Data public let secretKey: Data public let receivedTimestamp: TimeInterval public let threadKeyPairHash: String } try db.alter(table: ClosedGroupKeyPair.self) { t in t.add(.threadKeyPairHash, .text).defaults(to: "") } try db.create(table: TmpClosedGroupKeyPair.self) { t in t.column(.threadId, .text) .notNull() .references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted t.column(.publicKey, .blob).notNull() t.column(.secretKey, .blob).notNull() t.column(.receivedTimestamp, .double) .notNull() t.column(.threadKeyPairHash, .integer) .notNull() .unique() } // Insert into the new table, drop the old table and rename the new table to be the old one try ClosedGroupKeyPair .fetchAll(db) .map { keyPair in ClosedGroupKeyPair( threadId: keyPair.threadId, publicKey: keyPair.publicKey, secretKey: keyPair.secretKey, receivedTimestamp: keyPair.receivedTimestamp ) } .map { keyPair in TmpClosedGroupKeyPair( threadId: keyPair.threadId, publicKey: keyPair.publicKey, secretKey: keyPair.secretKey, receivedTimestamp: keyPair.receivedTimestamp, threadKeyPairHash: keyPair.threadKeyPairHash ) } .forEach { try? $0.insert(db) } // Ignore duplicate values try db.drop(table: ClosedGroupKeyPair.self) try db.rename(table: TmpClosedGroupKeyPair.databaseTableName, to: ClosedGroupKeyPair.databaseTableName) // Add an index for the 'ClosedGroupKeyPair' so we can lookup existing keys more easily // // Note: Need to create the indexes separately from creating 'TmpClosedGroupKeyPair' to ensure they // have the correct names try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadId]) try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.receivedTimestamp]) try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadKeyPairHash]) try db.createIndex( on: ClosedGroupKeyPair.self, columns: [.threadId, .threadKeyPairHash] ) // Add an index for the 'Quote' table to speed up queries try db.createIndex( on: Quote.self, columns: [.timestampMs] ) // New table for storing the latest config dump for each type try db.create(table: ConfigDump.self) { t in t.column(.variant, .text) .notNull() t.column(.publicKey, .text) .notNull() .indexed() t.column(.data, .blob) .notNull() t.column(.timestampMs, .integer) .notNull() .defaults(to: 0) t.primaryKey([.variant, .publicKey]) } // Migrate the 'isPinned' value to 'pinnedPriority' try SessionThread .filter(sql: "isPinned = true") .updateAll( db, SessionThread.Columns.pinnedPriority.set(to: 1) ) // If we don't have an ed25519 key then no need to create cached dump data let userPublicKey: String = getUserHexEncodedPublicKey(db) /// Remove any hidden threads to avoid syncing them (they are basically shadow threads created by starting a conversation /// but not sending a message so can just be cleared out) /// /// **Note:** Our settings defer foreign key checks to the end of the migration, unfortunately the `PRAGMA foreign_keys` /// setting is also a no-on during transactions so we can't enable it for the delete action, as a result we need to manually clean /// up any data associated with the threads we want to delete, at the time of this migration the following tables should cascade /// delete when a thread is deleted: /// - DisappearingMessagesConfiguration /// - ClosedGroup /// - GroupMember /// - Interaction /// - ThreadTypingIndicator /// - PendingReadReceipt let threadIdsToDelete: [String] = try SessionThread .filter( SessionThread.Columns.shouldBeVisible == false && SessionThread.Columns.id != userPublicKey ) .select(.id) .asRequest(of: String.self) .fetchAll(db) try SessionThread .deleteAll(db, ids: threadIdsToDelete) try DisappearingMessagesConfiguration .filter(threadIdsToDelete.contains(DisappearingMessagesConfiguration.Columns.threadId)) .deleteAll(db) try ClosedGroup .filter(threadIdsToDelete.contains(ClosedGroup.Columns.threadId)) .deleteAll(db) try GroupMember .filter(threadIdsToDelete.contains(GroupMember.Columns.groupId)) .deleteAll(db) try Interaction .filter(threadIdsToDelete.contains(Interaction.Columns.threadId)) .deleteAll(db) try ThreadTypingIndicator .filter(threadIdsToDelete.contains(ThreadTypingIndicator.Columns.threadId)) .deleteAll(db) try PendingReadReceipt .filter(threadIdsToDelete.contains(PendingReadReceipt.Columns.threadId)) .deleteAll(db) /// There was previously a bug which allowed users to fully delete the 'Note to Self' conversation but we don't want that, so /// create it again if it doesn't exists /// /// **Note:** Since migrations are run when running tests creating a random SessionThread will result in unexpected thread /// counts so don't do this when running tests (this logic is the same as in `MainAppContext.isRunningTests` if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { if (try SessionThread.exists(db, id: userPublicKey)) == false { try SessionThread .fetchOrCreate(db, id: userPublicKey, variant: .contact, shouldBeVisible: false) } } Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } }