// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import PromiseKit import SignalCoreKit public final class GRDBStorage { private static let dbFileName: String = "Session.sqlite" private static let keychainService: String = "TSKeyChainService" private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec" private static let kSQLCipherKeySpecLength: Int32 = 48 private static var sharedDatabaseDirectoryPath: String { "\(OWSFileSystem.appSharedDataDirectoryPath())/database" } private static var databasePath: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)" } private static var databasePathShm: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-shm" } private static var databasePathWal: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-wal" } public static var isDatabasePasswordAccessible: Bool { guard (try? getDatabaseCipherKeySpec()) != nil else { return false } return true } public static let shared: GRDBStorage = GRDBStorage() public private(set) var isValid: Bool = false public private(set) var hasCompletedMigrations: Bool = false private var dbPool: DatabasePool? private var migrator: DatabaseMigrator? private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>? // MARK: - Initialization public init() { print("RAWR START \("\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)")") GRDBStorage.deleteDatabaseFiles() // TODO: Remove this try! GRDBStorage.deleteDbKeys() // TODO: Remove this // Create the database directory if needed and ensure it's protection level is set before attempting to // create the database KeySpec or the database itself OWSFileSystem.ensureDirectoryExists(GRDBStorage.sharedDatabaseDirectoryPath) OWSFileSystem.protectFileOrFolder(atPath: GRDBStorage.sharedDatabaseDirectoryPath) // Generate the database KeySpec if needed (this MUST be done before we try to access the database // as a different thread might attempt to access the database before the key is successfully created) // // Note: We reset the bytes immediately after generation to ensure the database key doesn't hang // around in memory unintentionally var tmpKeySpec: Data = GRDBStorage.getOrGenerateDatabaseKeySpec() tmpKeySpec.resetBytes(in: 0.. ())?, onComplete: @escaping (Bool, Bool) -> () ) { guard isValid, let dbPool: DatabasePool = dbPool else { return } typealias MigrationInfo = (identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet) let sortedMigrationInfo: [MigrationInfo] = migrations .sorted() .reduce(into: [[MigrationInfo]]()) { result, next in next.migrations.enumerated().forEach { index, migrationSet in if result.count <= index { result.append([]) } result[index] = (result[index] + [(next.identifier, migrationSet)]) } } .reduce(into: []) { result, next in result.append(contentsOf: next) } // Setup and run any required migrations migrator = { var migrator: DatabaseMigrator = DatabaseMigrator() sortedMigrationInfo.forEach { migrationInfo in migrationInfo.migrations.forEach { migration in migrator.registerMigration(migrationInfo.identifier, migration: migration) } } return migrator }() // Determine which migrations need to be performed and gather the relevant settings needed to // inform the app of progress/states let completedMigrations: [String] = (try? dbPool.read { db in try migrator?.completedMigrations(db) }) .defaulting(to: []) let unperformedMigrations: [(key: String, migration: Migration.Type)] = sortedMigrationInfo .reduce(into: []) { result, next in next.migrations.forEach { migration in let key: String = next.identifier.key(with: migration) guard !completedMigrations.contains(key) else { return } result.append((key, migration)) } } let migrationToDurationMap: [String: TimeInterval] = unperformedMigrations .reduce(into: [:]) { result, next in result[next.key] = next.migration.minExpectedRunDuration } let unperformedMigrationDurations: [TimeInterval] = unperformedMigrations .map { _, migration in migration.minExpectedRunDuration } let totalMinExpectedDuration: TimeInterval = migrationToDurationMap.values.reduce(0, +) let needsConfigSync: Bool = unperformedMigrations .contains(where: { _, migration in migration.needsConfigSync }) self.migrationProgressUpdater = Atomic({ targetKey, progress in guard let migrationIndex: Int = unperformedMigrations.firstIndex(where: { key, _ in key == targetKey }) else { return } let completedExpectedDuration: TimeInterval = ( (migrationIndex > 0 ? unperformedMigrationDurations[0.. Data { return try CurrentAppContext().keychainStorage().data(forService: keychainService, key: dbCipherKeySpecKey) } @discardableResult private static func getOrGenerateDatabaseKeySpec() -> Data { do { var keySpec: Data = try getDatabaseCipherKeySpec() defer { keySpec.resetBytes(in: 0..(updates: (Database) throws -> T?) -> T? { guard isValid, let dbPool: DatabasePool = dbPool else { return nil } return try? dbPool.write(updates) } public func writeAsync(updates: @escaping (Database) throws -> T) { writeAsync(updates: updates, completion: { _, _ in }) } public func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { guard isValid, let dbPool: DatabasePool = dbPool else { return } dbPool.asyncWrite( updates, completion: { db, result in try? completion(db, result) } ) } @discardableResult public func read(_ value: (Database) throws -> T?) -> T? { guard isValid, let dbPool: DatabasePool = dbPool else { return nil } return try? dbPool.read(value) } /// Rever to the `ValueObservation.start` method for full documentation /// /// - parameter observation: The observation to start /// - parameter scheduler: A Scheduler. By default, fresh values are /// dispatched asynchronously on the main queue. /// - parameter onError: A closure that is provided eventual errors that /// happen during observation /// - parameter onChange: A closure that is provided fresh values /// - returns: a DatabaseCancellable public func start( _ observation: ValueObservation, scheduling scheduler: ValueObservationScheduler = .async(onQueue: .main), onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void ) -> DatabaseCancellable { guard isValid, let dbPool: DatabasePool = dbPool else { return AnyDatabaseCancellable(cancel: {}) } return observation.start( in: dbPool, scheduling: scheduler, onError: onError, onChange: onChange ) } public func addObserver(_ observer: TransactionObserver?) { guard isValid, let dbPool: DatabasePool = dbPool else { return } guard let observer: TransactionObserver = observer else { return } dbPool.add(transactionObserver: observer) } } // MARK: - Promise Extensions public extension GRDBStorage { // FIXME: Would be good to replace these with Swift Combine @discardableResult func read(_ value: (Database) throws -> Promise) -> Promise { guard isValid, let dbPool: DatabasePool = dbPool else { return Promise(error: StorageError.databaseInvalid) } do { return try dbPool.read(value) } catch { return Promise(error: error) } } @discardableResult func write(updates: (Database) throws -> Promise) -> Promise { guard isValid, let dbPool: DatabasePool = dbPool else { return Promise(error: StorageError.databaseInvalid) } do { return try dbPool.write(updates) } catch { return Promise(error: error) } } }