session-ios/SessionMessagingKit/Database/Migrations/_013_SessionUtilChanges.swift

249 lines
11 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
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 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)
.notNull()
.defaults(to: 0)
t.add(.lastProfilePictureUpdate, .integer)
.notNull()
.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(SessionThread.Columns.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
}
}