iOS/Sources/Shared/Common/Extensions/Realm+Initialization.swift

382 lines
16 KiB
Swift

import Foundation
import RealmSwift
#if os(iOS)
import UIKit
#endif
import PromiseKit
public extension Realm {
/// An in-memory data store, intended to be used in tests.
static let mock: () -> Realm = {
do {
return try Realm(configuration: Realm.Configuration(inMemoryIdentifier: "Memory"))
} catch {
fatalError("Error setting up Realm.mock! \(error)")
}
}
static var storeDirectoryURL: URL {
let fileManager = FileManager.default
let storeDirectoryURL = fileManager
.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.AppGroupID)?
.appendingPathComponent("dataStore", isDirectory: true)
if storeDirectoryURL == nil {
Current.Log.error("Unable to get directory URL! AppGroupID: \(AppConstants.AppGroupID)")
}
return storeDirectoryURL ?? URL(fileURLWithPath: NSTemporaryDirectory())
}
/// The live data store, located in shared storage.
static let live: () -> Realm = {
getRealm()
}
// swiftlint:disable cyclomatic_complexity
/// Mainly used to specify objectTypes in a context such as an extension, otherwise always use "Realm.live"
static func getRealm(objectTypes: [ObjectBase.Type]? = nil) -> Realm {
if NSClassFromString("XCTest") != nil {
do {
return try Realm(configuration: .init(inMemoryIdentifier: "Tests", deleteRealmIfMigrationNeeded: true))
} catch {
fatalError("couldn't create realm in unit test")
}
}
let fileManager = FileManager.default
let directoryURL = Realm.storeDirectoryURL
if !fileManager.fileExists(atPath: directoryURL.path) {
do {
let attributes =
[FileAttributeKey.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
try fileManager.createDirectory(
at: directoryURL,
withIntermediateDirectories: true,
attributes: attributes
)
} catch {
Realm.handleError(
message: "Error while attempting to create data store URL",
error: error
)
}
}
let storeURL = directoryURL.appendingPathComponent("store.realm", isDirectory: false)
#if targetEnvironment(simulator)
Current.Log.info("Realm is stored at \(storeURL.description)")
#endif
// 5 - 2020-07-08 v2020.4
// 6 - 2020-07-12 v2020.4
// 7 - 2020-07-20 v2020.5 (added RLMScene)
// 9 - 2020-07-23 v2020.5 (primary key removal on NotificationAction)
// 10 - 2020-07-31 v2020.5 (added isServerControlled to Action)
// 11 - 2020-08-12 v2020.5.2 (cleaning up duplicate NotificationCategory identifiers)
// 12 - 2020-08-16 v2020.6 (mdi upgrade/migration to 5.x)
// 13 - 2020-10-17 v2020.7 (allow multiple complications)
// 14 - 2020-10-29 v2020.8 (complication privacy)
// 15 - 2021-03-21 v2021.4 (scene properties)
// 16 - 2021-04-12 v2021.5 (accuracy authorization on location history entries)
// 17 - 2021-09-20 v2021.10 (added notification action key icon)
// 18 - 2021-11-15 v2021.12 (added server identifier keys to various models)
// 19 - 2021-11-27 v2021.12 (zone property renames)
// 2025 - 2022-08-13 v2022.x undoing realm automatic migration
// 26 - 2022-08-13 v2022.x bumping mdi version
// 27 - 2024-01-18 v2024.x adding CarPlay toggle to Actions
// 28 - 2024-07-29 v2024.x Add option to use custom colors
// Current schema version from database
if let currentSchemaVersion = try? schemaVersionAtURL(storeURL) {
Current.Log.verbose("Current schema version \(currentSchemaVersion)")
}
// New schema version
let schemaVersion: UInt64 = 28
Current.Log.verbose("Schema version defined: \(schemaVersion)")
let config = Realm.Configuration(
fileURL: storeURL,
schemaVersion: schemaVersion,
migrationBlock: { migration, oldVersion in
Current.Log.info("migrating from \(oldVersion)")
if oldVersion < 9 {
migration.enumerateObjects(ofType: NotificationAction.className()) { _, newObject in
newObject?["uuid"] = UUID().uuidString
}
}
if oldVersion < 10 {
migration.enumerateObjects(ofType: Action.className()) { _, newObject in
newObject?["isServerControlled"] = false
}
}
if oldVersion < 11 {
// Identifier is a primary key, and Realm is _suppose_ to prevent this from being possible
// but it seems like some time in the past, it allowed the same identifier to be inserted >1 time
var discoveredIdentifiers = Set<String>()
migration.enumerateObjects(ofType: NotificationCategory.className()) { _, newObject in
if let newObject, let identifier = newObject["Identifier"] as? String {
if discoveredIdentifiers.contains(identifier) {
migration.delete(newObject)
} else {
discoveredIdentifiers.insert(identifier)
}
}
}
}
if oldVersion < 13 {
migration.enumerateObjects(ofType: WatchComplication.className()) { _, newObject in
// initially creating these with their old family name
// this is so we migrate them to have identical names on both watch and phone, independently
// since future objects are created with a UUID-based identifier, this won't be an issue
// we also need to reference them by family for complications configured prior to watchOS 7
newObject!["identifier"] = newObject!["rawFamily"]
}
}
if oldVersion < 14 {
migration.enumerateObjects(ofType: WatchComplication.className()) { _, newObject in
newObject?["IsPublic"] = true
}
}
if oldVersion < 15 {
migration.enumerateObjects(ofType: RLMScene.className()) { oldObject, newObject in
if let data = oldObject?["underlyingSceneData"] as? Data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let attributes = json["attributes"] as? [String: Any] {
newObject?["backgroundColor"] = attributes["background_color"] as? String
newObject?["textColor"] = attributes["text_color"] as? String
newObject?["iconColor"] = attributes["icon_color"] as? String
newObject?["icon"] = attributes["icon"] as? String
newObject?["name"] = attributes["friendly_name"] as? String
}
}
}
if oldVersion < 16 {
// nothing, it added an optional
}
if oldVersion < 17 {
// nothing, it added an optional
}
if oldVersion < 18 {
// set the serverIdentifier to the historic value for anything synced earlier
func migrate(_ modelType: (some Object & UpdatableModel).Type) {
migration.enumerateObjects(ofType: modelType.className()) { _, newObject in
newObject?[modelType.serverIdentifierKey()] = Server.historicId.rawValue
}
}
migrate(NotificationCategory.self)
migrate(RLMScene.self)
migrate(RLMZone.self)
migrate(Action.self)
migration.enumerateObjects(ofType: WatchComplication.className()) { _, newObject in
newObject?["serverIdentifier"] = Server.historicId.rawValue
}
}
if oldVersion < 19 {
migration.renameProperty(onType: RLMZone.className(), from: "ID", to: "entityId")
migration.enumerateObjects(ofType: RLMZone.className()) { oldObject, newObject in
if let oldId = oldObject?["ID"] as? String,
let serverId = newObject?["serverIdentifier"] as? String {
let newId = RLMZone.primaryKey(sourceIdentifier: oldId, serverIdentifier: serverId)
Current.Log.info("change \(oldId) + \(serverId) to \(newId)")
newObject?["identifier"] = newId
}
}
}
if oldVersion < 27 {
migration.enumerateObjects(ofType: Action.className()) { _, newObject in
newObject?["showInCarPlay"] = true
newObject?["showInWatch"] = true
}
}
if oldVersion < 28 {
migration.enumerateObjects(ofType: Action.className()) { _, newObject in
newObject?["useCustomColors"] = false
}
}
do {
// always do an MDI migration, since micro-managing whether it needs to be done is annoying
migration.enumerateObjects(ofType: Action.className()) { _, newObject in
let iconNameKey = "IconName"
if let oldIconName = newObject?[iconNameKey] as? String {
newObject?[iconNameKey] = MDIMigration.migrate(icon: oldIconName)
}
}
migration.enumerateObjects(ofType: WatchComplication.className()) { _, newObject in
let dataKey = "complicationData"
let iconDictKey = "icon"
let iconDictIconKey = "icon"
if let oldData = newObject?[dataKey] as? Data,
let oldJson = try? JSONSerialization
.jsonObject(with: oldData) as? [String: Any],
let oldIconDict = oldJson[iconDictKey] as? [String: String],
let oldIconIcon = oldIconDict[iconDictIconKey] {
var updatedIconDict = oldIconDict
updatedIconDict[iconDictIconKey] = MDIMigration.migrate(icon: oldIconIcon)
var updatedJson = oldJson
updatedJson[iconDictKey] = updatedIconDict
if let newData = try? JSONSerialization.data(withJSONObject: updatedJson) {
newObject?[dataKey] = newData
}
}
}
}
},
deleteRealmIfMigrationNeeded: false,
shouldCompactOnLaunch: { realmFileSizeInBytes, usedBytes in
// from https://www.mongodb.com/docs/realm/sdk/swift/realm-files/compacting/
let maxFileSize = 10 * 1024 * 1024
// Check for the realm file size to be greater than the max file size, and the amount of bytes
// currently used to be less than 50% of the total realm file size
return (realmFileSizeInBytes > maxFileSize) && (Double(usedBytes) / Double(realmFileSizeInBytes)) < 0.5
},
objectTypes: objectTypes
)
do {
return try Realm(configuration: config)
} catch {
Current.crashReporter.logError(error as NSError)
Realm.handleError(
message: error.localizedDescription,
error: error
)
do {
// temporarily provide an in-memory instance so we don't crash
return try Realm(configuration: .init(inMemoryIdentifier: "Fallback"))
} catch {
fatalError(String(describing: error))
}
}
}
/// Backup the Realm database, returning the URL of the backup location.
static func backup() -> URL? {
let backupURL = Realm.storeDirectoryURL.appendingPathComponent("backup.realm")
if FileManager.default.fileExists(atPath: backupURL.path) {
do {
_ = try FileManager.default.removeItem(at: backupURL)
} catch {
Current.Log.error("Error while removing existing Realm backup: \(error)")
}
}
let realm = Realm.live()
realm.beginWrite()
do {
try realm.writeCopy(toFile: backupURL)
} catch {
Current.Log.error("Error while writing copy of database to URL \(backupURL): \(error)")
return nil
}
realm.cancelWrite()
return backupURL
}
/// Deletes all Realm objects
static func reset() {
let realm = Realm.live()
realm.beginWrite()
realm.deleteAll()
try? realm.commitWrite()
}
private static var hasShownError = false
private static func handleError(
message: String,
error: Swift.Error
) {
Current.crashReporter.logError(error as NSError)
Current.Log.error([message, error])
#if os(iOS)
DispatchQueue.main.async {
guard !hasShownError else {
return
}
hasShownError = true
let alert = UIAlertController(
title: L10n.Database.Problem.title,
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: L10n.Database.Problem.delete, style: .destructive, handler: { _ in
// swiftlint:disable:next force_try
try! FileManager.default.removeItem(at: storeDirectoryURL)
exit(1)
}))
alert.addAction(UIAlertAction(title: L10n.Database.Problem.quit, style: .cancel, handler: { _ in
exit(1)
}))
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { timer in
if let handler = Current.realmFatalPresentation {
handler(alert)
timer.invalidate()
}
})
timer.fire()
}
#else
fatalError("\(message) \(error.localizedDescription)")
#endif
}
@discardableResult
func reentrantWrite<Result>(
withoutNotifying tokens: [NotificationToken] = [],
_ block: () throws -> Result
) -> Promise<Result> {
let promise: Promise<Result>
if isInWriteTransaction {
promise = Promise { seal in
try seal.fulfill(block())
}
} else {
promise = Current.backgroundTask(withName: "realm-write") { _ in
Promise<Result> { seal in
try seal.fulfill(write(withoutNotifying: tokens, block))
}
}
}
promise.catch { error in
Current.Log.error(error)
}
return promise
}
}