mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			254 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			254 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
| // 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 let droppedTables: [(TableRecord & FetchableRecord).Type] = []
 | |
|     
 | |
|     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
 | |
|     }
 | |
| }
 |