mirror of
https://github.com/JohnEstropia/CoreStore.git
synced 2026-03-20 00:24:01 +01:00
WIP: ICloudStore prototype
This commit is contained in:
@@ -131,6 +131,30 @@ public extension CoreStore {
|
||||
return try self.defaultStack.addStorageAndWait(storage)
|
||||
}
|
||||
|
||||
/**
|
||||
Adds a `CloudStorage` to the `defaultStack` and blocks until completion.
|
||||
```
|
||||
try CoreStore.addStorageAndWait(
|
||||
ICloudStore(
|
||||
ubiquitousContentName: "MyAppCloudData",
|
||||
ubiquitousContentTransactionLogsSubdirectory: "logs/config1",
|
||||
ubiquitousContainerID: "iCloud.com.mycompany.myapp.containername",
|
||||
ubiquitousPeerToken: "9614d658014f4151a95d8048fb717cf0",
|
||||
configuration: "Config1",
|
||||
cloudStorageOptions: .AllowSynchronousLightweightMigration
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
- parameter storage: the local storage
|
||||
- throws: a `CoreStoreError` value indicating the failure
|
||||
- returns: the cloud storage added to the stack. Note that this may not always be the same instance as the parameter argument if a previous `CloudStorage` was already added at the same URL and with the same configuration.
|
||||
*/
|
||||
public static func addStorageAndWait<T: CloudStorage>(storage: T) throws -> T {
|
||||
|
||||
return try self.defaultStack.addStorageAndWait(storage)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Deprecated
|
||||
|
||||
|
||||
@@ -238,12 +238,10 @@ public final class DataStack {
|
||||
|
||||
do {
|
||||
|
||||
var storeOptions = storage.storeOptions ?? [:]
|
||||
if storage.localStorageOptions.contains(.AllowSynchronousLightweightMigration) {
|
||||
|
||||
storeOptions[NSMigratePersistentStoresAutomaticallyOption] = true
|
||||
storeOptions[NSInferMappingModelAutomaticallyOption] = true
|
||||
}
|
||||
var localStorageOptions = storage.localStorageOptions
|
||||
localStorageOptions.remove(.RecreateStoreOnModelMismatch)
|
||||
|
||||
let storeOptions = storage.storeOptionsForOptions(localStorageOptions)
|
||||
do {
|
||||
|
||||
try NSFileManager.defaultManager().createDirectoryAtURL(
|
||||
@@ -287,6 +285,100 @@ public final class DataStack {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Adds a `CloudStorage` to the stack and blocks until completion.
|
||||
```
|
||||
try dataStack.addStorageAndWait(
|
||||
ICloudStore(
|
||||
ubiquitousContentName: "MyAppCloudData",
|
||||
ubiquitousContentTransactionLogsSubdirectory: "logs/config1",
|
||||
ubiquitousContainerID: "iCloud.com.mycompany.myapp.containername",
|
||||
ubiquitousPeerToken: "9614d658014f4151a95d8048fb717cf0",
|
||||
configuration: "Config1",
|
||||
cloudStorageOptions: .AllowSynchronousLightweightMigration
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
- parameter storage: the local storage
|
||||
- throws: a `CoreStoreError` value indicating the failure
|
||||
- returns: the cloud storage added to the stack. Note that this may not always be the same instance as the parameter argument if a previous `CloudStorage` was already added at the same URL and with the same configuration.
|
||||
*/
|
||||
public func addStorageAndWait<T: CloudStorage>(storage: T) throws -> T {
|
||||
|
||||
return try self.coordinator.performSynchronously {
|
||||
|
||||
if let _ = self.persistentStoreForStorage(storage) {
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
let cacheFileURL = storage.cacheFileURL
|
||||
if let persistentStore = self.coordinator.persistentStoreForURL(cacheFileURL) {
|
||||
|
||||
if let existingStorage = persistentStore.storageInterface as? T
|
||||
where storage.matchesPersistentStore(persistentStore) {
|
||||
|
||||
return existingStorage
|
||||
}
|
||||
|
||||
let error = CoreStoreError.DifferentStorageExistsAtURL(existingPersistentStoreURL: cacheFileURL)
|
||||
CoreStore.log(
|
||||
error,
|
||||
"Failed to add \(typeName(storage)) at \"\(cacheFileURL)\" because a different \(typeName(NSPersistentStore)) at that URL already exists."
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
do {
|
||||
|
||||
var cloudStorageOptions = storage.cloudStorageOptions
|
||||
cloudStorageOptions.remove(.RecreateLocalStoreOnModelMismatch)
|
||||
|
||||
let storeOptions = storage.storeOptionsForOptions(cloudStorageOptions)
|
||||
do {
|
||||
|
||||
try NSFileManager.defaultManager().createDirectoryAtURL(
|
||||
cacheFileURL.URLByDeletingLastPathComponent!,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
try self.createPersistentStoreFromStorage(
|
||||
storage,
|
||||
finalURL: cacheFileURL,
|
||||
finalStoreOptions: storeOptions
|
||||
)
|
||||
return storage
|
||||
}
|
||||
catch let error as NSError where storage.cloudStorageOptions.contains(.RecreateLocalStoreOnModelMismatch) && error.isCoreDataMigrationError {
|
||||
|
||||
let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStoreOfType(
|
||||
storage.dynamicType.storeType,
|
||||
URL: cacheFileURL,
|
||||
options: storeOptions
|
||||
)
|
||||
try _ = self.model[metadata].flatMap(storage.eraseStorageAndWait)
|
||||
|
||||
try self.createPersistentStoreFromStorage(
|
||||
storage,
|
||||
finalURL: cacheFileURL,
|
||||
finalStoreOptions: storeOptions
|
||||
)
|
||||
return storage
|
||||
}
|
||||
}
|
||||
catch {
|
||||
|
||||
let storeError = CoreStoreError(error)
|
||||
CoreStore.log(
|
||||
storeError,
|
||||
"Failed to add \(typeName(storage)) to the stack."
|
||||
)
|
||||
throw storeError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Internal
|
||||
|
||||
@@ -407,6 +499,7 @@ public final class DataStack {
|
||||
self.entityConfigurationsMapping[managedObjectClassName]?.insert(configurationName)
|
||||
}
|
||||
}
|
||||
storage.didAddToDataStack(self)
|
||||
return persistentStore
|
||||
}
|
||||
|
||||
|
||||
494
Sources/Setup/StorageInterfaces/ICloudStore.swift
Normal file
494
Sources/Setup/StorageInterfaces/ICloudStore.swift
Normal file
@@ -0,0 +1,494 @@
|
||||
//
|
||||
// ICloudStore.swift
|
||||
// CoreStore
|
||||
//
|
||||
// Copyright © 2016 John Rommel Estropia
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
|
||||
// MARK: - ICloudStore
|
||||
|
||||
/**
|
||||
A storage interface backed by an SQLite database managed by iCloud.
|
||||
*/
|
||||
public class ICloudStore: CloudStorage {
|
||||
|
||||
/**
|
||||
Initializes an iCloud store interface from the given ubiquitous store information. Returns `nil` if the container could not be located or if iCloud storage is unavailable for the current user or device
|
||||
```
|
||||
try CoreStore.addStorage(
|
||||
ICloudStore(
|
||||
ubiquitousContentName: "MyAppCloudData",
|
||||
ubiquitousContentTransactionLogsSubdirectory: "logs/config1",
|
||||
ubiquitousContainerID: "iCloud.com.mycompany.myapp.containername",
|
||||
ubiquitousPeerToken: "9614d658014f4151a95d8048fb717cf0",
|
||||
configuration: "Config1",
|
||||
cloudStorageOptions: .AllowSynchronousLightweightMigration
|
||||
)
|
||||
completion: { result in
|
||||
// ...
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- parameter ubiquitousContentName: the name of the store in iCloud. This is required and should not be empty, and should not contain periods (`.`).
|
||||
- parameter ubiquitousContentTransactionLogsSubdirectory: an optional subdirectory path for the transaction logs
|
||||
- parameter ubiquitousContainerID: a container if your app has multiple ubiquity container identifiers in its entitlements
|
||||
- parameter ubiquitousPeerToken: a per-application salt to allow multiple apps on the same device to share a Core Data store integrated with iCloud
|
||||
- parameter configuration: an optional configuration name from the model file. If not specified, defaults to `nil`, the "Default" configuration. Note that if you have multiple configurations, you will need to specify a different `ubiquitousContentName` explicitly for each of them.
|
||||
- parameter mappingModelBundles: a list of `NSBundle`s from which to search mapping models for migration.
|
||||
- parameter cloudStorageOptions: When the `ICloudStore` is passed to the `DataStack`'s `addStorage()` methods, tells the `DataStack` how to setup the persistent store. Defaults to `.None`.
|
||||
*/
|
||||
public required init?(ubiquitousContentName: String, ubiquitousContentTransactionLogsSubdirectory: String? = nil, ubiquitousContainerID: String? = nil, ubiquitousPeerToken: String? = nil, configuration: String? = nil, cloudStorageOptions: CloudStorageOptions = nil) {
|
||||
|
||||
CoreStore.assert(
|
||||
!ubiquitousContentName.isEmpty,
|
||||
"The ubiquitousContentName cannot be empty."
|
||||
)
|
||||
CoreStore.assert(
|
||||
!ubiquitousContentName.containsString("."),
|
||||
"The ubiquitousContentName cannot contain periods."
|
||||
)
|
||||
CoreStore.assert(
|
||||
ubiquitousContentTransactionLogsSubdirectory?.isEmpty != true,
|
||||
"The ubiquitousContentURLRelativePath should not be empty if provided."
|
||||
)
|
||||
CoreStore.assert(
|
||||
ubiquitousPeerToken?.isEmpty != true,
|
||||
"The ubiquitousPeerToken should not be empty if provided."
|
||||
)
|
||||
|
||||
let fileManager = NSFileManager.defaultManager()
|
||||
guard let cacheFileURL = fileManager.URLForUbiquityContainerIdentifier(ubiquitousContainerID) else {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var storeOptions: [String: AnyObject] = [
|
||||
NSSQLitePragmasOption: ["journal_mode": "WAL"],
|
||||
NSPersistentStoreUbiquitousContentNameKey: ubiquitousContentName
|
||||
]
|
||||
storeOptions[NSPersistentStoreUbiquitousContentURLKey] = ubiquitousContentTransactionLogsSubdirectory
|
||||
storeOptions[NSPersistentStoreUbiquitousContainerIdentifierKey] = ubiquitousContainerID
|
||||
storeOptions[NSPersistentStoreUbiquitousPeerTokenOption] = ubiquitousPeerToken
|
||||
|
||||
self.cacheFileURL = cacheFileURL
|
||||
self.configuration = configuration
|
||||
self.cloudStorageOptions = cloudStorageOptions
|
||||
self.storeOptions = storeOptions
|
||||
}
|
||||
|
||||
public func addUbiquitousStoreObserver<T: ICloudStoreObserver>(observer: T) {
|
||||
|
||||
CoreStore.assert(
|
||||
NSThread.isMainThread(),
|
||||
"Attempted to add an observer of type \(typeName(observer)) outside the main thread."
|
||||
)
|
||||
|
||||
self.removeUbiquitousStoreObserver(observer)
|
||||
|
||||
self.registerNotification(
|
||||
&self.willFinishInitialImportKey,
|
||||
name: ICloudUbiquitousStoreWillFinishInitialImportNotification,
|
||||
toObserver: observer,
|
||||
callback: { (observer, storage, dataStack) in
|
||||
|
||||
observer.iCloudStoreWillFinishUbiquitousStoreInitialImport(storage: storage, dataStack: dataStack)
|
||||
}
|
||||
)
|
||||
self.registerNotification(
|
||||
&self.didFinishInitialImportKey,
|
||||
name: ICloudUbiquitousStoreDidFinishInitialImportNotification,
|
||||
toObserver: observer,
|
||||
callback: { (observer, storage, dataStack) in
|
||||
|
||||
observer.iCloudStoreDidFinishUbiquitousStoreInitialImport(storage: storage, dataStack: dataStack)
|
||||
}
|
||||
)
|
||||
self.registerNotification(
|
||||
&self.willAddAccountKey,
|
||||
name: ICloudUbiquitousStoreWillAddAccountNotification,
|
||||
toObserver: observer,
|
||||
callback: { (observer, storage, dataStack) in
|
||||
|
||||
observer.iCloudStoreWillAddAccount(storage: storage, dataStack: dataStack)
|
||||
}
|
||||
)
|
||||
self.registerNotification(
|
||||
&self.didAddAccountKey,
|
||||
name: ICloudUbiquitousStoreDidAddAccountNotification,
|
||||
toObserver: observer,
|
||||
callback: { (observer, storage, dataStack) in
|
||||
|
||||
observer.iCloudStoreDidAddAccount(storage: storage, dataStack: dataStack)
|
||||
}
|
||||
)
|
||||
self.registerNotification(
|
||||
&self.willRemoveAccountKey,
|
||||
name: ICloudUbiquitousStoreWillRemoveAccountNotification,
|
||||
toObserver: observer,
|
||||
callback: { (observer, storage, dataStack) in
|
||||
|
||||
observer.iCloudStoreWillRemoveAccount(storage: storage, dataStack: dataStack)
|
||||
}
|
||||
)
|
||||
self.registerNotification(
|
||||
&self.didRemoveAccountKey,
|
||||
name: ICloudUbiquitousStoreDidRemoveAccountNotification,
|
||||
toObserver: observer,
|
||||
callback: { (observer, storage, dataStack) in
|
||||
|
||||
observer.iCloudStoreDidRemoveAccount(storage: storage, dataStack: dataStack)
|
||||
}
|
||||
)
|
||||
self.registerNotification(
|
||||
&self.willRemoveContentKey,
|
||||
name: ICloudUbiquitousStoreWillRemoveContentNotification,
|
||||
toObserver: observer,
|
||||
callback: { (observer, storage, dataStack) in
|
||||
|
||||
observer.iCloudStoreWillRemoveContent(storage: storage, dataStack: dataStack)
|
||||
}
|
||||
)
|
||||
self.registerNotification(
|
||||
&self.didRemoveContentKey,
|
||||
name: ICloudUbiquitousStoreDidRemoveContentNotification,
|
||||
toObserver: observer,
|
||||
callback: { (observer, storage, dataStack) in
|
||||
|
||||
observer.iCloudStoreDidRemoveContent(storage: storage, dataStack: dataStack)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func removeUbiquitousStoreObserver(observer: ICloudStoreObserver) {
|
||||
|
||||
CoreStore.assert(
|
||||
NSThread.isMainThread(),
|
||||
"Attempted to remove an observer of type \(typeName(observer)) outside the main thread."
|
||||
)
|
||||
let nilValue: AnyObject? = nil
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &self.willFinishInitialImportKey,
|
||||
inObject: observer
|
||||
)
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &self.didFinishInitialImportKey,
|
||||
inObject: observer
|
||||
)
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &self.willAddAccountKey,
|
||||
inObject: observer
|
||||
)
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &self.didAddAccountKey,
|
||||
inObject: observer
|
||||
)
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &self.willRemoveAccountKey,
|
||||
inObject: observer
|
||||
)
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &self.didRemoveAccountKey,
|
||||
inObject: observer
|
||||
)
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &self.willRemoveContentKey,
|
||||
inObject: observer
|
||||
)
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &self.didRemoveContentKey,
|
||||
inObject: observer
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: StorageInterface
|
||||
|
||||
/**
|
||||
The string identifier for the `NSPersistentStore`'s `type` property. For `SQLiteStore`s, this is always set to `NSSQLiteStoreType`.
|
||||
*/
|
||||
public static let storeType = NSSQLiteStoreType
|
||||
|
||||
/**
|
||||
The configuration name in the model file
|
||||
*/
|
||||
public let configuration: String?
|
||||
|
||||
/**
|
||||
The options dictionary for the `NSPersistentStore`. For `SQLiteStore`s, this is always set to
|
||||
```
|
||||
[NSSQLitePragmasOption: ["journal_mode": "WAL"]]
|
||||
```
|
||||
*/
|
||||
public let storeOptions: [String: AnyObject]?
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
public func didAddToDataStack(dataStack: DataStack) {
|
||||
|
||||
self.didRemoveFromDataStack(dataStack)
|
||||
|
||||
self.dataStack = dataStack
|
||||
let coordinator = dataStack.coordinator
|
||||
|
||||
setAssociatedRetainedObject(
|
||||
NotificationObserver(
|
||||
notificationName: NSPersistentStoreCoordinatorStoresWillChangeNotification,
|
||||
object: coordinator,
|
||||
closure: { [weak self, weak dataStack] (note) -> Void in
|
||||
|
||||
guard let `self` = self,
|
||||
let dataStack = dataStack,
|
||||
let userInfo = note.userInfo,
|
||||
let transitionType = userInfo[NSPersistentStoreUbiquitousTransitionTypeKey] as? NSNumber else {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let notification: String
|
||||
switch NSPersistentStoreUbiquitousTransitionType(rawValue: transitionType.unsignedIntegerValue) {
|
||||
|
||||
case .InitialImportCompleted?:
|
||||
notification = ICloudUbiquitousStoreWillFinishInitialImportNotification
|
||||
|
||||
case .AccountAdded?:
|
||||
notification = ICloudUbiquitousStoreWillAddAccountNotification
|
||||
|
||||
case .AccountRemoved?:
|
||||
notification = ICloudUbiquitousStoreWillRemoveAccountNotification
|
||||
|
||||
case .ContentRemoved?:
|
||||
notification = ICloudUbiquitousStoreWillRemoveContentNotification
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(
|
||||
notification,
|
||||
object: self,
|
||||
userInfo: [UserInfoKeyDataStack: dataStack]
|
||||
)
|
||||
}
|
||||
),
|
||||
forKey: &Static.persistentStoreCoordinatorWillChangeStores,
|
||||
inObject: self
|
||||
)
|
||||
setAssociatedRetainedObject(
|
||||
NotificationObserver(
|
||||
notificationName: NSPersistentStoreCoordinatorStoresDidChangeNotification,
|
||||
object: coordinator,
|
||||
closure: { [weak self, weak dataStack] (note) -> Void in
|
||||
|
||||
guard let `self` = self,
|
||||
let dataStack = dataStack,
|
||||
let userInfo = note.userInfo,
|
||||
let transitionType = userInfo[NSPersistentStoreUbiquitousTransitionTypeKey] as? NSNumber else {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let notification: String
|
||||
switch NSPersistentStoreUbiquitousTransitionType(rawValue: transitionType.unsignedIntegerValue) {
|
||||
|
||||
case .InitialImportCompleted?:
|
||||
notification = ICloudUbiquitousStoreDidFinishInitialImportNotification
|
||||
|
||||
case .AccountAdded?:
|
||||
notification = ICloudUbiquitousStoreDidAddAccountNotification
|
||||
|
||||
case .AccountRemoved?:
|
||||
notification = ICloudUbiquitousStoreDidRemoveAccountNotification
|
||||
|
||||
case .ContentRemoved?:
|
||||
notification = ICloudUbiquitousStoreDidRemoveContentNotification
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(
|
||||
notification,
|
||||
object: self,
|
||||
userInfo: [UserInfoKeyDataStack: dataStack]
|
||||
)
|
||||
}
|
||||
),
|
||||
forKey: &Static.persistentStoreCoordinatorDidChangeStores,
|
||||
inObject: self
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
public func didRemoveFromDataStack(dataStack: DataStack) {
|
||||
|
||||
let coordinator = dataStack.coordinator
|
||||
let nilValue: AnyObject? = nil
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &Static.persistentStoreCoordinatorWillChangeStores,
|
||||
inObject: coordinator
|
||||
)
|
||||
setAssociatedRetainedObject(
|
||||
nilValue,
|
||||
forKey: &Static.persistentStoreCoordinatorDidChangeStores,
|
||||
inObject: coordinator
|
||||
)
|
||||
|
||||
self.dataStack = nil
|
||||
}
|
||||
|
||||
|
||||
// MARK: CloudStorage
|
||||
|
||||
/**
|
||||
The `NSURL` that points to the ubiquity container file
|
||||
*/
|
||||
public let cacheFileURL: NSURL
|
||||
|
||||
/**
|
||||
Options that tell the `DataStack` how to setup the persistent store
|
||||
*/
|
||||
public var cloudStorageOptions: CloudStorageOptions
|
||||
|
||||
/**
|
||||
The options dictionary for the specified `CloudStorageOptions`
|
||||
*/
|
||||
public func storeOptionsForOptions(options: CloudStorageOptions) -> [String: AnyObject]? {
|
||||
|
||||
if options == .None {
|
||||
|
||||
return self.storeOptions
|
||||
}
|
||||
|
||||
var storeOptions = self.storeOptions ?? [:]
|
||||
if options.contains(.AllowSynchronousLightweightMigration) {
|
||||
|
||||
storeOptions[NSMigratePersistentStoresAutomaticallyOption] = true
|
||||
storeOptions[NSInferMappingModelAutomaticallyOption] = true
|
||||
}
|
||||
if options.contains(.RecreateLocalStoreOnModelMismatch) {
|
||||
|
||||
storeOptions[NSPersistentStoreRebuildFromUbiquitousContentOption] = true
|
||||
}
|
||||
return storeOptions
|
||||
}
|
||||
|
||||
/**
|
||||
Called by the `DataStack` to perform actual deletion of the store file from disk. Do not call directly! The `sourceModel` argument is a hint for the existing store's model version. For `SQLiteStore`, this converts the database's WAL journaling mode to DELETE before deleting the file.
|
||||
*/
|
||||
public func eraseStorageAndWait(soureModel soureModel: NSManagedObjectModel) throws {
|
||||
|
||||
// TODO: check if attached to persistent store
|
||||
|
||||
let cacheFileURL = self.cacheFileURL
|
||||
try autoreleasepool {
|
||||
|
||||
let journalUpdatingCoordinator = NSPersistentStoreCoordinator(managedObjectModel: soureModel)
|
||||
let options = [
|
||||
NSSQLitePragmasOption: ["journal_mode": "DELETE"],
|
||||
NSPersistentStoreRemoveUbiquitousMetadataOption: true
|
||||
]
|
||||
let store = try journalUpdatingCoordinator.addPersistentStoreWithType(
|
||||
self.dynamicType.storeType,
|
||||
configuration: self.configuration,
|
||||
URL: cacheFileURL,
|
||||
options: options
|
||||
)
|
||||
try journalUpdatingCoordinator.removePersistentStore(store)
|
||||
try NSPersistentStoreCoordinator.removeUbiquitousContentAndPersistentStoreAtURL(
|
||||
cacheFileURL,
|
||||
options: options
|
||||
)
|
||||
try NSFileManager.defaultManager().removeItemAtURL(cacheFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private struct Static {
|
||||
|
||||
private static var persistentStoreCoordinatorWillChangeStores: Void?
|
||||
private static var persistentStoreCoordinatorDidChangeStores: Void?
|
||||
}
|
||||
|
||||
private var willFinishInitialImportKey: Void?
|
||||
private var didFinishInitialImportKey: Void?
|
||||
private var willAddAccountKey: Void?
|
||||
private var didAddAccountKey: Void?
|
||||
private var willRemoveAccountKey: Void?
|
||||
private var didRemoveAccountKey: Void?
|
||||
private var willRemoveContentKey: Void?
|
||||
private var didRemoveContentKey: Void?
|
||||
|
||||
private weak var dataStack: DataStack?
|
||||
|
||||
private func registerNotification<T: ICloudStoreObserver>(notificationKey: UnsafePointer<Void>, name: String, toObserver observer: T, callback: (observer: T, storage: ICloudStore, dataStack: DataStack) -> Void) {
|
||||
|
||||
setAssociatedRetainedObject(
|
||||
NotificationObserver(
|
||||
notificationName: name,
|
||||
object: self,
|
||||
closure: { [weak self, weak observer] (note) -> Void in
|
||||
|
||||
guard let `self` = self,
|
||||
let observer = observer,
|
||||
let dataStack = note.userInfo?[UserInfoKeyDataStack] as? DataStack
|
||||
where self.dataStack === dataStack else {
|
||||
|
||||
return
|
||||
}
|
||||
callback(observer: observer, storage: self, dataStack: dataStack)
|
||||
}
|
||||
),
|
||||
forKey: notificationKey,
|
||||
inObject: observer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Notification Keys
|
||||
|
||||
private let ICloudUbiquitousStoreWillFinishInitialImportNotification = "ICloudUbiquitousStoreWillFinishInitialImportNotification"
|
||||
private let ICloudUbiquitousStoreDidFinishInitialImportNotification = "ICloudUbiquitousStoreDidFinishInitialImportNotification"
|
||||
private let ICloudUbiquitousStoreWillAddAccountNotification = "ICloudUbiquitousStoreWillAddAccountNotification"
|
||||
private let ICloudUbiquitousStoreDidAddAccountNotification = "ICloudUbiquitousStoreDidAddAccountNotification"
|
||||
private let ICloudUbiquitousStoreWillRemoveAccountNotification = "ICloudUbiquitousStoreWillRemoveAccountNotification"
|
||||
private let ICloudUbiquitousStoreDidRemoveAccountNotification = "ICloudUbiquitousStoreDidRemoveAccountNotification"
|
||||
private let ICloudUbiquitousStoreWillRemoveContentNotification = "ICloudUbiquitousStoreWillRemoveContentNotification"
|
||||
private let ICloudUbiquitousStoreDidRemoveContentNotification = "ICloudUbiquitousStoreDidRemoveContentNotification"
|
||||
|
||||
private let UserInfoKeyDataStack = "UserInfoKeyDataStack"
|
||||
59
Sources/Setup/StorageInterfaces/ICloudStoreObserver.swift
Normal file
59
Sources/Setup/StorageInterfaces/ICloudStoreObserver.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// ICloudStoreObserver.swift
|
||||
// CoreStore
|
||||
//
|
||||
// Copyright © 2016 John Rommel Estropia
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
// MARK: - ICloudStoreObserver
|
||||
|
||||
public protocol ICloudStoreObserver: class {
|
||||
|
||||
func iCloudStoreWillFinishUbiquitousStoreInitialImport(storage storage: ICloudStore, dataStack: DataStack)
|
||||
func iCloudStoreDidFinishUbiquitousStoreInitialImport(storage storage: ICloudStore, dataStack: DataStack)
|
||||
|
||||
func iCloudStoreWillAddAccount(storage storage: ICloudStore, dataStack: DataStack)
|
||||
func iCloudStoreDidAddAccount(storage storage: ICloudStore, dataStack: DataStack)
|
||||
|
||||
func iCloudStoreWillRemoveAccount(storage storage: ICloudStore, dataStack: DataStack)
|
||||
func iCloudStoreDidRemoveAccount(storage storage: ICloudStore, dataStack: DataStack)
|
||||
|
||||
func iCloudStoreWillRemoveContent(storage storage: ICloudStore, dataStack: DataStack)
|
||||
func iCloudStoreDidRemoveContent(storage storage: ICloudStore, dataStack: DataStack)
|
||||
}
|
||||
|
||||
public extension ICloudStoreObserver {
|
||||
|
||||
public func iCloudStoreWillFinishUbiquitousStoreInitialImport(storage storage: ICloudStore, dataStack: DataStack) {}
|
||||
public func iCloudStoreDidFinishUbiquitousStoreInitialImport(storage storage: ICloudStore, dataStack: DataStack) {}
|
||||
|
||||
public func iCloudStoreWillAddAccount(storage storage: ICloudStore, dataStack: DataStack) {}
|
||||
public func iCloudStoreDidAddAccount(storage storage: ICloudStore, dataStack: DataStack) {}
|
||||
|
||||
public func iCloudStoreWillRemoveAccount(storage storage: ICloudStore, dataStack: DataStack) {}
|
||||
public func iCloudStoreDidRemoveAccount(storage storage: ICloudStore, dataStack: DataStack) {}
|
||||
|
||||
public func iCloudStoreWillRemoveContent(storage storage: ICloudStore, dataStack: DataStack) {}
|
||||
public func iCloudStoreDidRemoveContent(storage storage: ICloudStore, dataStack: DataStack) {}
|
||||
}
|
||||
@@ -70,4 +70,25 @@ public final class InMemoryStore: StorageInterface, DefaultInitializableStore {
|
||||
The options dictionary for the `NSPersistentStore`. For `InMemoryStore`s, this is always set to `nil`.
|
||||
*/
|
||||
public let storeOptions: [String: AnyObject]? = nil
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
public func didAddToDataStack(dataStack: DataStack) {
|
||||
|
||||
self.dataStack = dataStack
|
||||
}
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
public func didRemoveFromDataStack(dataStack: DataStack) {
|
||||
|
||||
self.dataStack = nil
|
||||
}
|
||||
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private weak var dataStack: DataStack?
|
||||
}
|
||||
|
||||
@@ -88,6 +88,62 @@ public final class LegacySQLiteStore: LocalStorage, DefaultInitializableStore {
|
||||
}
|
||||
|
||||
|
||||
// MARK: StorageInterface
|
||||
|
||||
/**
|
||||
The string identifier for the `NSPersistentStore`'s `type` property. For `SQLiteStore`s, this is always set to `NSSQLiteStoreType`.
|
||||
*/
|
||||
public static let storeType = NSSQLiteStoreType
|
||||
|
||||
/**
|
||||
The options dictionary for the specified `LocalStorageOptions`
|
||||
*/
|
||||
public func storeOptionsForOptions(options: LocalStorageOptions) -> [String: AnyObject]? {
|
||||
|
||||
if options == .None {
|
||||
|
||||
return self.storeOptions
|
||||
}
|
||||
|
||||
var storeOptions = self.storeOptions ?? [:]
|
||||
if options.contains(.AllowSynchronousLightweightMigration) {
|
||||
|
||||
storeOptions[NSMigratePersistentStoresAutomaticallyOption] = true
|
||||
storeOptions[NSInferMappingModelAutomaticallyOption] = true
|
||||
}
|
||||
return storeOptions
|
||||
}
|
||||
|
||||
/**
|
||||
The configuration name in the model file
|
||||
*/
|
||||
public let configuration: String?
|
||||
|
||||
/**
|
||||
The options dictionary for the `NSPersistentStore`. For `SQLiteStore`s, this is always set to
|
||||
```
|
||||
[NSSQLitePragmasOption: ["journal_mode": "WAL"]]
|
||||
```
|
||||
*/
|
||||
public let storeOptions: [String: AnyObject]? = [NSSQLitePragmasOption: ["journal_mode": "WAL"]]
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
public func didAddToDataStack(dataStack: DataStack) {
|
||||
|
||||
self.dataStack = dataStack
|
||||
}
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
public func didRemoveFromDataStack(dataStack: DataStack) {
|
||||
|
||||
self.dataStack = nil
|
||||
}
|
||||
|
||||
|
||||
// MAKR: LocalStorage
|
||||
|
||||
/**
|
||||
@@ -105,27 +161,6 @@ public final class LegacySQLiteStore: LocalStorage, DefaultInitializableStore {
|
||||
*/
|
||||
public var localStorageOptions: LocalStorageOptions
|
||||
|
||||
|
||||
// MARK: StorageInterface
|
||||
|
||||
/**
|
||||
The string identifier for the `NSPersistentStore`'s `type` property. For `SQLiteStore`s, this is always set to `NSSQLiteStoreType`.
|
||||
*/
|
||||
public static let storeType = NSSQLiteStoreType
|
||||
|
||||
/**
|
||||
The configuration name in the model file
|
||||
*/
|
||||
public let configuration: String?
|
||||
|
||||
/**
|
||||
The options dictionary for the `NSPersistentStore`. For `SQLiteStore`s, this is always set to
|
||||
```
|
||||
[NSSQLitePragmasOption: ["journal_mode": "WAL"]]
|
||||
```
|
||||
*/
|
||||
public let storeOptions: [String: AnyObject]? = [NSSQLitePragmasOption: ["journal_mode": "WAL"]]
|
||||
|
||||
/**
|
||||
Called by the `DataStack` to perform actual deletion of the store file from disk. Do not call directly! The `sourceModel` argument is a hint for the existing store's model version. For `SQLiteStore`, this converts the database's WAL journaling mode to DELETE before deleting the file.
|
||||
*/
|
||||
@@ -168,4 +203,9 @@ public final class LegacySQLiteStore: LocalStorage, DefaultInitializableStore {
|
||||
internal static let defaultFileURL = LegacySQLiteStore.defaultRootDirectory
|
||||
.URLByAppendingPathComponent(DataStack.applicationName, isDirectory: false)
|
||||
.URLByAppendingPathExtension("sqlite")
|
||||
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private weak var dataStack: DataStack?
|
||||
}
|
||||
|
||||
@@ -86,24 +86,6 @@ public final class SQLiteStore: LocalStorage, DefaultInitializableStore {
|
||||
}
|
||||
|
||||
|
||||
// MAKR: LocalStorage
|
||||
|
||||
/**
|
||||
The `NSURL` that points to the SQLite file
|
||||
*/
|
||||
public let fileURL: NSURL
|
||||
|
||||
/**
|
||||
The `NSBundle`s from which to search mapping models for migrations
|
||||
*/
|
||||
public let mappingModelBundles: [NSBundle]
|
||||
|
||||
/**
|
||||
Options that tell the `DataStack` how to setup the persistent store
|
||||
*/
|
||||
public var localStorageOptions: LocalStorageOptions
|
||||
|
||||
|
||||
// MARK: StorageInterface
|
||||
|
||||
/**
|
||||
@@ -123,13 +105,66 @@ public final class SQLiteStore: LocalStorage, DefaultInitializableStore {
|
||||
```
|
||||
*/
|
||||
public let storeOptions: [String: AnyObject]? = [NSSQLitePragmasOption: ["journal_mode": "WAL"]]
|
||||
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
public func didAddToDataStack(dataStack: DataStack) {
|
||||
|
||||
self.dataStack = dataStack
|
||||
}
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
public func didRemoveFromDataStack(dataStack: DataStack) {
|
||||
|
||||
self.dataStack = nil
|
||||
}
|
||||
|
||||
|
||||
// MAKR: LocalStorage
|
||||
|
||||
/**
|
||||
The `NSURL` that points to the SQLite file
|
||||
*/
|
||||
public let fileURL: NSURL
|
||||
|
||||
/**
|
||||
The `NSBundle`s from which to search mapping models for migrations
|
||||
*/
|
||||
public let mappingModelBundles: [NSBundle]
|
||||
|
||||
/**
|
||||
Options that tell the `DataStack` how to setup the persistent store
|
||||
*/
|
||||
public var localStorageOptions: LocalStorageOptions
|
||||
|
||||
/**
|
||||
The options dictionary for the specified `LocalStorageOptions`
|
||||
*/
|
||||
public func storeOptionsForOptions(options: LocalStorageOptions) -> [String: AnyObject]? {
|
||||
|
||||
if options == .None {
|
||||
|
||||
return self.storeOptions
|
||||
}
|
||||
|
||||
var storeOptions = self.storeOptions ?? [:]
|
||||
if options.contains(.AllowSynchronousLightweightMigration) {
|
||||
|
||||
storeOptions[NSMigratePersistentStoresAutomaticallyOption] = true
|
||||
storeOptions[NSInferMappingModelAutomaticallyOption] = true
|
||||
}
|
||||
return storeOptions
|
||||
}
|
||||
|
||||
/**
|
||||
Called by the `DataStack` to perform actual deletion of the store file from disk. Do not call directly! The `sourceModel` argument is a hint for the existing store's model version. For `SQLiteStore`, this converts the database's WAL journaling mode to DELETE before deleting the file.
|
||||
*/
|
||||
public func eraseStorageAndWait(soureModel soureModel: NSManagedObjectModel) throws {
|
||||
|
||||
// TODO: check if attached to persistent store
|
||||
// TODO: check if attached to persistent store
|
||||
|
||||
let fileURL = self.fileURL
|
||||
try autoreleasepool {
|
||||
@@ -147,7 +182,7 @@ public final class SQLiteStore: LocalStorage, DefaultInitializableStore {
|
||||
}
|
||||
|
||||
|
||||
// MARK: Private
|
||||
// MARK: Internal
|
||||
|
||||
internal static let defaultRootDirectory: NSURL = {
|
||||
|
||||
@@ -173,4 +208,9 @@ public final class SQLiteStore: LocalStorage, DefaultInitializableStore {
|
||||
isDirectory: false
|
||||
)
|
||||
.URLByAppendingPathExtension("sqlite")
|
||||
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private weak var dataStack: DataStack?
|
||||
}
|
||||
|
||||
@@ -47,6 +47,19 @@ public protocol StorageInterface: class {
|
||||
The options dictionary for the `NSPersistentStore`
|
||||
*/
|
||||
var storeOptions: [String: AnyObject]? { get }
|
||||
|
||||
|
||||
// MARK: Internal (Do not call these directly)
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
func didAddToDataStack(dataStack: DataStack)
|
||||
|
||||
/**
|
||||
Do not call directly. Used by the `DataStack` internally.
|
||||
*/
|
||||
func didRemoveFromDataStack(dataStack: DataStack)
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +105,7 @@ public struct LocalStorageOptions: OptionSetType, NilLiteralConvertible {
|
||||
public static let AllowSynchronousLightweightMigration = LocalStorageOptions(rawValue: 1 << 2)
|
||||
|
||||
|
||||
|
||||
// MARK: OptionSetType
|
||||
|
||||
public init(rawValue: Int) {
|
||||
@@ -136,15 +150,17 @@ public protocol LocalStorage: StorageInterface {
|
||||
*/
|
||||
var localStorageOptions: LocalStorageOptions { get }
|
||||
|
||||
/**
|
||||
The options dictionary for the specified `LocalStorageOptions`
|
||||
*/
|
||||
func storeOptionsForOptions(options: LocalStorageOptions) -> [String: AnyObject]?
|
||||
|
||||
/**
|
||||
Called by the `DataStack` to perform actual deletion of the store file from disk. **Do not call directly!** The `sourceModel` argument is a hint for the existing store's model version. Implementers can use the `sourceModel` to perform necessary store operations. (SQLite stores for example, can convert WAL journaling mode to DELETE before deleting)
|
||||
*/
|
||||
func eraseStorageAndWait(soureModel soureModel: NSManagedObjectModel) throws
|
||||
}
|
||||
|
||||
|
||||
// MARK: Internal
|
||||
|
||||
internal extension LocalStorage {
|
||||
|
||||
internal func matchesPersistentStore(persistentStore: NSPersistentStore) -> Bool {
|
||||
@@ -154,3 +170,105 @@ internal extension LocalStorage {
|
||||
&& persistentStore.URL == self.fileURL
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - CloudStorageOptions
|
||||
|
||||
/**
|
||||
The `CloudStorageOptions` provides settings that tells the `DataStack` how to setup the persistent store for `LocalStorage` implementers.
|
||||
*/
|
||||
public struct CloudStorageOptions: OptionSetType, NilLiteralConvertible {
|
||||
|
||||
/**
|
||||
Tells the `DataStack` that the store should not be migrated or recreated, and should simply fail on model mismatch
|
||||
*/
|
||||
public static let None = CloudStorageOptions(rawValue: 0)
|
||||
|
||||
/**
|
||||
Tells the `DataStack` to delete and recreate the local store from the cloud store on model mismatch, otherwise exceptions will be thrown on failure instead
|
||||
*/
|
||||
public static let RecreateLocalStoreOnModelMismatch = CloudStorageOptions(rawValue: 1 << 0)
|
||||
|
||||
/**
|
||||
Tells the `DataStack` to allow lightweight migration for the store when added synchronously
|
||||
*/
|
||||
public static let AllowSynchronousLightweightMigration = CloudStorageOptions(rawValue: 1 << 2)
|
||||
|
||||
|
||||
// MARK: OptionSetType
|
||||
|
||||
public init(rawValue: Int) {
|
||||
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
|
||||
// MARK: RawRepresentable
|
||||
|
||||
public let rawValue: Int
|
||||
|
||||
|
||||
// MARK: NilLiteralConvertible
|
||||
|
||||
public init(nilLiteral: ()) {
|
||||
|
||||
self.rawValue = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - CloudStorage
|
||||
|
||||
/**
|
||||
The `CloudStorage` represents `StorageInterface`s that are synchronized from a cloud-based store.
|
||||
*/
|
||||
public protocol CloudStorage: StorageInterface {
|
||||
|
||||
/**
|
||||
The `NSURL` that points to the store file
|
||||
*/
|
||||
var cacheFileURL: NSURL { get }
|
||||
|
||||
/**
|
||||
Options that tell the `DataStack` how to setup the persistent store
|
||||
*/
|
||||
var cloudStorageOptions: CloudStorageOptions { get }
|
||||
|
||||
/**
|
||||
The options dictionary for the specified `CloudStorageOptions`
|
||||
*/
|
||||
func storeOptionsForOptions(options: CloudStorageOptions) -> [String: AnyObject]?
|
||||
|
||||
/**
|
||||
Called by the `DataStack` to perform actual deletion of the store file from disk. **Do not call directly!** The `sourceModel` argument is a hint for the existing store's model version. Implementers can use the `sourceModel` to perform necessary store operations. (Cloud stores for example, can set the NSPersistentStoreRemoveUbiquitousMetadataOption option before deleting)
|
||||
*/
|
||||
func eraseStorageAndWait(soureModel soureModel: NSManagedObjectModel) throws
|
||||
}
|
||||
|
||||
internal extension CloudStorage {
|
||||
|
||||
internal func matchesPersistentStore(persistentStore: NSPersistentStore) -> Bool {
|
||||
|
||||
guard persistentStore.type == self.dynamicType.storeType
|
||||
&& persistentStore.configurationName == (self.configuration ?? Into.defaultConfigurationName) else {
|
||||
|
||||
return false
|
||||
}
|
||||
guard persistentStore.URL == self.cacheFileURL else {
|
||||
|
||||
return false
|
||||
}
|
||||
guard let persistentStoreOptions = persistentStore.options,
|
||||
let storeOptions = self.storeOptions else {
|
||||
|
||||
return persistentStore.options == nil && self.storeOptions == nil
|
||||
}
|
||||
return storeOptions.reduce(true) { (isMatch, tuple) in
|
||||
|
||||
let (key, value) = tuple
|
||||
let obj1 = persistentStoreOptions[key] as? NSObject
|
||||
let obj2 = value as? NSObject
|
||||
return isMatch && (obj1 == obj2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user