//
// C o p y r i g h t ( c ) 2 0 1 9 O p e n W h i s p e r S y s t e m s . A l l r i g h t s r e s e r v e d .
//
import Foundation
import SignalUtilitiesKit
import CloudKit
import PromiseKit
// W e d o n ' t w o r r y a b o u t a t o m i c w r i t e s . E a c h b a c k u p e x p o r t
// w i l l d i f f a g a i n s t l a s t s u c c e s s f u l b a c k u p .
//
// N o t e t h a t a l l o f o u r C l o u d K i t r e c o r d s a r e i m m u t a b l e .
// " P e r s i s t e n t " r e c o r d s a r e o n l y u p l o a d e d o n c e .
// " E p h e m e r a l " r e c o r d s a r e a l w a y s u p l o a d e d t o a n e w r e c o r d n a m e .
@objc public class OWSBackupAPI : NSObject {
// I f w e c h a n g e t h e r e c o r d t y p e s , w e n e e d t o e n s u r e i n d i c e s
// a r e c o n f i g u r e d p r o p e r l y i n t h e C l o u d K i t d a s h b o a r d .
//
// TODO: C h a n g e t h e r e c o r d t y p e s w h e n w e s h i p t o p r o d u c t i o n .
static let signalBackupRecordType = " signalBackup "
static let manifestRecordNameSuffix = " manifest "
static let payloadKey = " payload "
static let maxRetries = 5
private class func database ( ) -> CKDatabase {
let myContainer = CKContainer . default ( )
let privateDatabase = myContainer . privateCloudDatabase
return privateDatabase
}
private class func invalidServiceResponseError ( ) -> Error {
return OWSErrorWithCodeDescription ( . backupFailure ,
NSLocalizedString ( " BACKUP_EXPORT_ERROR_INVALID_CLOUDKIT_RESPONSE " ,
comment : " Error indicating that the app received an invalid response from CloudKit. " ) )
}
// MARK: - U p l o a d
@objc
public class func recordNameForTestFile ( recipientId : String ) -> String {
return " \( recordNamePrefix ( forRecipientId : recipientId ) ) test- \( NSUUID ( ) . uuidString ) "
}
// " E p h e m e r a l " f i l e s a r e s p e c i f i c t o t h i s b a c k u p e x p o r t a n d w i l l a l w a y s n e e d t o
// b e s a v e d . F o r e x a m p l e , a c o m p l e t e i m a g e o f t h e d a t a b a s e i s e x p o r t e d e a c h t i m e .
// W e w o u l d n ' t w a n t t o o v e r w r i t e p r e v i o u s i m a g e s u n t i l t h e e n t i r e b a c k u p e x p o r t i s
// c o m p l e t e .
@objc
public class func recordNameForEphemeralFile ( recipientId : String ,
label : String ) -> String {
return " \( recordNamePrefix ( forRecipientId : recipientId ) ) ephemeral- \( label ) - \( NSUUID ( ) . uuidString ) "
}
// " P e r s i s t e n t " f i l e s m a y b e s h a r e d b e t w e e n b a c k u p e x p o r t ; t h e y s h o u l d o n l y b e s a v e d
// o n c e . F o r e x a m p l e , a t t a c h m e n t f i l e s s h o u l d o n l y b e u p l o a d e d o n c e . S u b s e q u e n t
// b a c k u p s c a n r e u s e t h e s a m e r e c o r d .
@objc
public class func recordNameForPersistentFile ( recipientId : String ,
fileId : String ) -> String {
return " \( recordNamePrefix ( forRecipientId : recipientId ) ) persistentFile- \( fileId ) "
}
// " P e r s i s t e n t " f i l e s m a y b e s h a r e d b e t w e e n b a c k u p e x p o r t ; t h e y s h o u l d o n l y b e s a v e d
// o n c e . F o r e x a m p l e , a t t a c h m e n t f i l e s s h o u l d o n l y b e u p l o a d e d o n c e . S u b s e q u e n t
// b a c k u p s c a n r e u s e t h e s a m e r e c o r d .
@objc
public class func recordNameForManifest ( recipientId : String ) -> String {
return " \( recordNamePrefix ( forRecipientId : recipientId ) ) \( manifestRecordNameSuffix ) "
}
private class func isManifest ( recordName : String ) -> Bool {
return recordName . hasSuffix ( manifestRecordNameSuffix )
}
private class func recordNamePrefix ( forRecipientId recipientId : String ) -> String {
return " \( recipientId ) - "
}
private class func recipientId ( forRecordName recordName : String ) -> String ? {
let recipientIds = self . recipientIds ( forRecordNames : [ recordName ] )
guard let recipientId = recipientIds . first else {
return nil
}
return recipientId
}
private static var recordNamePrefixRegex = {
return try ! NSRegularExpression ( pattern : " ^( \\ +[0-9]+) \\ - " )
} ( )
private class func recipientIds ( forRecordNames recordNames : [ String ] ) -> [ String ] {
var recipientIds = [ String ] ( )
for recordName in recordNames {
let regex = recordNamePrefixRegex
guard let match : NSTextCheckingResult = regex . firstMatch ( in : recordName , options : [ ] , range : NSRange ( location : 0 , length : recordName . utf16 . count ) ) else {
Logger . warn ( " no match: \( recordName ) " )
continue
}
guard match . numberOfRanges > 0 else {
// M a t c h m u s t i n c l u d e f i r s t g r o u p .
Logger . warn ( " invalid match: \( recordName ) " )
continue
}
let firstRange = match . range ( at : 1 )
guard firstRange . location = = 0 ,
firstRange . length > 0 else {
// M a t c h m u s t b e a t s t a r t o f s t r i n g a n d n o n - e m p t y .
Logger . warn ( " invalid match: \( recordName ) \( firstRange ) " )
continue
}
let recipientId = ( recordName as NSString ) . substring ( with : firstRange ) as String
recipientIds . append ( recipientId )
}
return recipientIds
}
@objc
public class func record ( forFileUrl fileUrl : URL ,
recordName : String ) -> CKRecord {
let recordType = signalBackupRecordType
let recordID = CKRecord . ID ( recordName : recordName )
let record = CKRecord ( recordType : recordType , recordID : recordID )
let asset = CKAsset ( fileURL : fileUrl )
record [ payloadKey ] = asset
return record
}
@objc
public class func saveRecordsToCloudObjc ( records : [ CKRecord ] ) -> AnyPromise {
return AnyPromise ( saveRecordsToCloud ( records : records ) )
}
public class func saveRecordsToCloud ( records : [ CKRecord ] ) -> Promise < Void > {
// C l o u d K i t ' s i n t e r n a l l i m i t i s 4 0 0 , b u t I h a v e n ' t f o u n d a c o n s t a n t f o r t h i s .
let kMaxBatchSize = 100
return records . chunked ( by : kMaxBatchSize ) . reduce ( Promise . value ( ( ) ) ) { ( promise , batch ) -> Promise < Void > in
return promise . then ( on : . global ( ) ) {
saveRecordsToCloud ( records : batch , remainingRetries : maxRetries )
} . done {
Logger . verbose ( " Saved batch: \( batch . count ) " )
}
}
}
private class func saveRecordsToCloud ( records : [ CKRecord ] ,
remainingRetries : Int ) -> Promise < Void > {
let recordNames = records . map { ( record ) in
return record . recordID . recordName
}
Logger . verbose ( " recordNames[ \( recordNames . count ) ] \( recordNames [ 0. . < 10 ] ) ... " )
return Promise { resolver in
let saveOperation = CKModifyRecordsOperation ( recordsToSave : records , recordIDsToDelete : nil )
saveOperation . modifyRecordsCompletionBlock = { ( savedRecords : [ CKRecord ] ? , _ , error ) in
let retry = {
// O n l y r e t r y r e c o r d s w h i c h d i d n ' t a l r e a d y s u c c e e d .
var savedRecordNames = [ String ] ( )
if let savedRecords = savedRecords {
savedRecordNames = savedRecords . map { ( record ) in
return record . recordID . recordName
}
}
let retryRecords = records . filter ( { ( record ) in
return ! savedRecordNames . contains ( record . recordID . recordName )
} )
saveRecordsToCloud ( records : retryRecords ,
remainingRetries : remainingRetries - 1 )
. done { _ in
resolver . fulfill ( ( ) )
} . catch { ( error ) in
resolver . reject ( error )
} . retainUntilComplete ( )
}
let outcome = outcomeForCloudKitError ( error : error ,
remainingRetries : remainingRetries ,
label : " Save Records[ \( recordNames . count ) ] " )
switch outcome {
case . success :
resolver . fulfill ( ( ) )
case . failureDoNotRetry ( let outcomeError ) :
resolver . reject ( outcomeError )
case . failureRetryAfterDelay ( let retryDelay ) :
DispatchQueue . global ( ) . asyncAfter ( deadline : DispatchTime . now ( ) + retryDelay , execute : {
retry ( )
} )
case . failureRetryWithoutDelay :
DispatchQueue . global ( ) . async {
retry ( )
}
case . unknownItem :
owsFailDebug ( " unexpected CloudKit response. " )
resolver . reject ( invalidServiceResponseError ( ) )
}
}
saveOperation . isAtomic = false
saveOperation . savePolicy = . allKeys
// TODO: u s e p e r R e c o r d P r o g r e s s B l o c k a n d p e r R e c o r d C o m p l e t i o n B l o c k .
// o p e n v a r p e r R e c o r d P r o g r e s s B l o c k : ( ( C K R e c o r d , D o u b l e ) - > V o i d ) ?
// o p e n v a r p e r R e c o r d C o m p l e t i o n B l o c k : ( ( C K R e c o r d , E r r o r ? ) - > V o i d ) ?
// T h e s e A P I s a r e o n l y a v a i l a b l e i n i O S 9 . 3 a n d l a t e r .
if #available ( iOS 9.3 , * ) {
saveOperation . isLongLived = true
saveOperation . qualityOfService = . background
}
database ( ) . add ( saveOperation )
}
}
// MARK: - D e l e t e
@objc
public class func deleteRecordsFromCloud ( recordNames : [ String ] ,
success : @ escaping ( ) -> Void ,
failure : @ escaping ( Error ) -> Void ) {
deleteRecordsFromCloud ( recordNames : recordNames ,
remainingRetries : maxRetries ,
success : success ,
failure : failure )
}
private class func deleteRecordsFromCloud ( recordNames : [ String ] ,
remainingRetries : Int ,
success : @ escaping ( ) -> Void ,
failure : @ escaping ( Error ) -> Void ) {
let recordIDs = recordNames . map { CKRecord . ID ( recordName : $0 ) }
let deleteOperation = CKModifyRecordsOperation ( recordsToSave : nil , recordIDsToDelete : recordIDs )
deleteOperation . modifyRecordsCompletionBlock = { ( records , recordIds , error ) in
let outcome = outcomeForCloudKitError ( error : error ,
remainingRetries : remainingRetries ,
label : " Delete Records " )
switch outcome {
case . success :
success ( )
case . failureDoNotRetry ( let outcomeError ) :
failure ( outcomeError )
case . failureRetryAfterDelay ( let retryDelay ) :
DispatchQueue . global ( ) . asyncAfter ( deadline : DispatchTime . now ( ) + retryDelay , execute : {
deleteRecordsFromCloud ( recordNames : recordNames ,
remainingRetries : remainingRetries - 1 ,
success : success ,
failure : failure )
} )
case . failureRetryWithoutDelay :
DispatchQueue . global ( ) . async {
deleteRecordsFromCloud ( recordNames : recordNames ,
remainingRetries : remainingRetries - 1 ,
success : success ,
failure : failure )
}
case . unknownItem :
owsFailDebug ( " unexpected CloudKit response. " )
failure ( invalidServiceResponseError ( ) )
}
}
database ( ) . add ( deleteOperation )
}
// MARK: - E x i s t s ?
private class func checkForFileInCloud ( recordName : String ,
remainingRetries : Int ) -> Promise < CKRecord ? > {
Logger . verbose ( " checkForFileInCloud \( recordName ) " )
let ( promise , resolver ) = Promise < CKRecord ? > . pending ( )
let recordId = CKRecord . ID ( recordName : recordName )
let fetchOperation = CKFetchRecordsOperation ( recordIDs : [ recordId ] )
// D o n ' t d o w n l o a d t h e f i l e ; w e ' r e j u s t u s i n g t h e f e t c h t o c h e c k w h e t h e r o r
// n o t t h i s r e c o r d a l r e a d y e x i s t s .
fetchOperation . desiredKeys = [ ]
fetchOperation . perRecordCompletionBlock = { ( record , recordId , error ) in
let outcome = outcomeForCloudKitError ( error : error ,
remainingRetries : remainingRetries ,
label : " Check for Record " )
switch outcome {
case . success :
guard let record = record else {
owsFailDebug ( " missing fetching record. " )
resolver . reject ( invalidServiceResponseError ( ) )
return
}
// R e c o r d f o u n d .
resolver . fulfill ( record )
case . failureDoNotRetry ( let outcomeError ) :
resolver . reject ( outcomeError )
case . failureRetryAfterDelay ( let retryDelay ) :
DispatchQueue . global ( ) . asyncAfter ( deadline : DispatchTime . now ( ) + retryDelay , execute : {
checkForFileInCloud ( recordName : recordName ,
remainingRetries : remainingRetries - 1 )
. done { ( record ) in
resolver . fulfill ( record )
} . catch { ( error ) in
resolver . reject ( error )
} . retainUntilComplete ( )
} )
case . failureRetryWithoutDelay :
DispatchQueue . global ( ) . async {
checkForFileInCloud ( recordName : recordName ,
remainingRetries : remainingRetries - 1 )
. done { ( record ) in
resolver . fulfill ( record )
} . catch { ( error ) in
resolver . reject ( error )
} . retainUntilComplete ( )
}
case . unknownItem :
// R e c o r d n o t f o u n d .
resolver . fulfill ( nil )
}
}
database ( ) . add ( fetchOperation )
return promise
}
@objc
public class func checkForManifestInCloudObjc ( recipientId : String ) -> AnyPromise {
return AnyPromise ( checkForManifestInCloud ( recipientId : recipientId ) )
}
public class func checkForManifestInCloud ( recipientId : String ) -> Promise < Bool > {
let recordName = recordNameForManifest ( recipientId : recipientId )
return checkForFileInCloud ( recordName : recordName ,
remainingRetries : maxRetries )
. map { ( record ) in
return record != nil
}
}
@objc
public class func allRecipientIdsWithManifestsInCloud ( success : @ escaping ( [ String ] ) -> Void ,
failure : @ escaping ( Error ) -> Void ) {
let processResults = { ( recordNames : [ String ] ) in
DispatchQueue . global ( ) . async {
let manifestRecordNames = recordNames . filter ( { ( recordName ) -> Bool in
self . isManifest ( recordName : recordName )
} )
let recipientIds = self . recipientIds ( forRecordNames : manifestRecordNames )
success ( recipientIds )
}
}
let query = CKQuery ( recordType : signalBackupRecordType , predicate : NSPredicate ( value : true ) )
// F e t c h t h e f i r s t p a g e o f r e s u l t s f o r t h i s q u e r y .
fetchAllRecordNamesStep ( recipientId : nil ,
query : query ,
previousRecordNames : [ String ] ( ) ,
cursor : nil ,
remainingRetries : maxRetries ,
success : processResults ,
failure : failure )
}
@objc
public class func fetchAllRecordNames ( recipientId : String ,
success : @ escaping ( [ String ] ) -> Void ,
failure : @ escaping ( Error ) -> Void ) {
let query = CKQuery ( recordType : signalBackupRecordType , predicate : NSPredicate ( value : true ) )
// F e t c h t h e f i r s t p a g e o f r e s u l t s f o r t h i s q u e r y .
fetchAllRecordNamesStep ( recipientId : recipientId ,
query : query ,
previousRecordNames : [ String ] ( ) ,
cursor : nil ,
remainingRetries : maxRetries ,
success : success ,
failure : failure )
}
private class func fetchAllRecordNamesStep ( recipientId : String ? ,
query : CKQuery ,
previousRecordNames : [ String ] ,
cursor : CKQueryOperation . Cursor ? ,
remainingRetries : Int ,
success : @ escaping ( [ String ] ) -> Void ,
failure : @ escaping ( Error ) -> Void ) {
var allRecordNames = previousRecordNames
let queryOperation = CKQueryOperation ( query : query )
// I f t h i s i s n ' t t h e f i r s t p a g e o f r e s u l t s f o r t h i s q u e r y , r e s u m e
// w h e r e w e l e f t o f f .
queryOperation . cursor = cursor
// D o n ' t d o w n l o a d t h e f i l e ; w e ' r e j u s t u s i n g t h e q u e r y t o g e t a l i s t o f r e c o r d n a m e s .
queryOperation . desiredKeys = [ ]
queryOperation . recordFetchedBlock = { ( record ) in
assert ( record . recordID . recordName . count > 0 )
let recordName = record . recordID . recordName
if let recipientId = recipientId {
let prefix = recordNamePrefix ( forRecipientId : recipientId )
guard recordName . hasPrefix ( prefix ) else {
Logger . info ( " Ignoring record: \( recordName ) " )
return
}
}
allRecordNames . append ( recordName )
}
queryOperation . queryCompletionBlock = { ( cursor , error ) in
let outcome = outcomeForCloudKitError ( error : error ,
remainingRetries : remainingRetries ,
label : " Fetch All Records " )
switch outcome {
case . success :
if let cursor = cursor {
Logger . verbose ( " fetching more record names \( allRecordNames . count ) . " )
// T h e r e a r e m o r e p a g e s o f r e s u l t s , c o n t i n u e f e t c h i n g .
fetchAllRecordNamesStep ( recipientId : recipientId ,
query : query ,
previousRecordNames : allRecordNames ,
cursor : cursor ,
remainingRetries : maxRetries ,
success : success ,
failure : failure )
return
}
Logger . info ( " fetched \( allRecordNames . count ) record names. " )
success ( allRecordNames )
case . failureDoNotRetry ( let outcomeError ) :
failure ( outcomeError )
case . failureRetryAfterDelay ( let retryDelay ) :
DispatchQueue . global ( ) . asyncAfter ( deadline : DispatchTime . now ( ) + retryDelay , execute : {
fetchAllRecordNamesStep ( recipientId : recipientId ,
query : query ,
previousRecordNames : allRecordNames ,
cursor : cursor ,
remainingRetries : remainingRetries - 1 ,
success : success ,
failure : failure )
} )
case . failureRetryWithoutDelay :
DispatchQueue . global ( ) . async {
fetchAllRecordNamesStep ( recipientId : recipientId ,
query : query ,
previousRecordNames : allRecordNames ,
cursor : cursor ,
remainingRetries : remainingRetries - 1 ,
success : success ,
failure : failure )
}
case . unknownItem :
owsFailDebug ( " unexpected CloudKit response. " )
failure ( invalidServiceResponseError ( ) )
}
}
database ( ) . add ( queryOperation )
}
// MARK: - D o w n l o a d
@objc
public class func downloadManifestFromCloudObjc ( recipientId : String ) -> AnyPromise {
return AnyPromise ( downloadManifestFromCloud ( recipientId : recipientId ) )
}
public class func downloadManifestFromCloud ( recipientId : String ) -> Promise < Data > {
let recordName = recordNameForManifest ( recipientId : recipientId )
return downloadDataFromCloud ( recordName : recordName )
}
@objc
public class func downloadDataFromCloudObjc ( recordName : String ) -> AnyPromise {
return AnyPromise ( downloadDataFromCloud ( recordName : recordName ) )
}
public class func downloadDataFromCloud ( recordName : String ) -> Promise < Data > {
return downloadFromCloud ( recordName : recordName ,
remainingRetries : maxRetries )
. map { ( asset ) -> Data in
guard let fileURL = asset . fileURL else {
throw invalidServiceResponseError ( )
}
return try Data ( contentsOf : fileURL )
}
}
@objc
public class func downloadFileFromCloudObjc ( recordName : String ,
toFileUrl : URL ) -> AnyPromise {
return AnyPromise ( downloadFileFromCloud ( recordName : recordName ,
toFileUrl : toFileUrl ) )
}
public class func downloadFileFromCloud ( recordName : String ,
toFileUrl : URL ) -> Promise < Void > {
return downloadFromCloud ( recordName : recordName ,
remainingRetries : maxRetries )
. done { asset in
guard let fileURL = asset . fileURL else {
throw invalidServiceResponseError ( )
}
try FileManager . default . copyItem ( at : fileURL , to : toFileUrl )
}
}
// W e r e t u r n t h e C K A s s e t a n d n o t i t s f i l e U r l b e c a u s e
// C l o u d K i t o f f e r s n o g u a r a n t e e s a r o u n d h o w l o n g i t ' l l
// k e e p a r o u n d t h e u n d e r l y i n g f i l e . P r e s u m a b l y w e c a n
// d e f e r c l e a n u p b y m a i n t a i n i n g a s t r o n g r e f e r e n c e t o
// t h e a s s e t .
private class func downloadFromCloud ( recordName : String ,
remainingRetries : Int ) -> Promise < CKAsset > {
Logger . verbose ( " downloadFromCloud \( recordName ) " )
let ( promise , resolver ) = Promise < CKAsset > . pending ( )
let recordId = CKRecord . ID ( recordName : recordName )
let fetchOperation = CKFetchRecordsOperation ( recordIDs : [ recordId ] )
// D o w n l o a d a l l k e y s f o r t h i s r e c o r d .
fetchOperation . perRecordCompletionBlock = { ( record , recordId , error ) in
let outcome = outcomeForCloudKitError ( error : error ,
remainingRetries : remainingRetries ,
label : " Download Record " )
switch outcome {
case . success :
guard let record = record else {
Logger . error ( " missing fetching record. " )
resolver . reject ( invalidServiceResponseError ( ) )
return
}
guard let asset = record [ payloadKey ] as ? CKAsset else {
Logger . error ( " record missing payload. " )
resolver . reject ( invalidServiceResponseError ( ) )
return
}
resolver . fulfill ( asset )
case . failureDoNotRetry ( let outcomeError ) :
resolver . reject ( outcomeError )
case . failureRetryAfterDelay ( let retryDelay ) :
DispatchQueue . global ( ) . asyncAfter ( deadline : DispatchTime . now ( ) + retryDelay , execute : {
downloadFromCloud ( recordName : recordName ,
remainingRetries : remainingRetries - 1 )
. done { ( asset ) in
resolver . fulfill ( asset )
} . catch { ( error ) in
resolver . reject ( error )
} . retainUntilComplete ( )
} )
case . failureRetryWithoutDelay :
DispatchQueue . global ( ) . async {
downloadFromCloud ( recordName : recordName ,
remainingRetries : remainingRetries - 1 )
. done { ( asset ) in
resolver . fulfill ( asset )
} . catch { ( error ) in
resolver . reject ( error )
} . retainUntilComplete ( )
}
case . unknownItem :
Logger . error ( " missing fetching record. " )
resolver . reject ( invalidServiceResponseError ( ) )
}
}
database ( ) . add ( fetchOperation )
return promise
}
// MARK: - A c c e s s
@objc public enum BackupError : Int , Error {
case couldNotDetermineAccountStatus
case noAccount
case restrictedAccountStatus
}
@objc
public class func ensureCloudKitAccessObjc ( ) -> AnyPromise {
return AnyPromise ( ensureCloudKitAccess ( ) )
}
public class func ensureCloudKitAccess ( ) -> Promise < Void > {
let ( promise , resolver ) = Promise < Void > . pending ( )
CKContainer . default ( ) . accountStatus { ( accountStatus , error ) in
if let error = error {
Logger . error ( " Unknown error: \( String ( describing : error ) ) . " )
resolver . reject ( error )
return
}
switch accountStatus {
case . couldNotDetermine :
Logger . error ( " could not determine CloudKit account status: \( String ( describing : error ) ) . " )
resolver . reject ( BackupError . couldNotDetermineAccountStatus )
case . noAccount :
Logger . error ( " no CloudKit account. " )
resolver . reject ( BackupError . noAccount )
case . restricted :
Logger . error ( " restricted CloudKit account. " )
resolver . reject ( BackupError . restrictedAccountStatus )
case . available :
Logger . verbose ( " CloudKit access okay. " )
resolver . fulfill ( ( ) )
default : resolver . fulfill ( ( ) )
}
}
return promise
}
@objc
public class func errorMessage ( forCloudKitAccessError error : Error ) -> String {
if let backupError = error as ? BackupError {
Logger . error ( " Backup error: \( String ( describing : backupError ) ) . " )
switch backupError {
case . couldNotDetermineAccountStatus :
return NSLocalizedString ( " CLOUDKIT_STATUS_COULD_NOT_DETERMINE " , comment : " Error indicating that the app could not determine that user's iCloud account status " )
case . noAccount :
return NSLocalizedString ( " CLOUDKIT_STATUS_NO_ACCOUNT " , comment : " Error indicating that user does not have an iCloud account. " )
case . restrictedAccountStatus :
return NSLocalizedString ( " CLOUDKIT_STATUS_RESTRICTED " , comment : " Error indicating that the app was prevented from accessing the user's iCloud account. " )
}
} else {
Logger . error ( " Unknown error: \( String ( describing : error ) ) . " )
return NSLocalizedString ( " CLOUDKIT_STATUS_COULD_NOT_DETERMINE " , comment : " Error indicating that the app could not determine that user's iCloud account status " )
}
}
// MARK: - R e t r y
private enum APIOutcome {
case success
case failureDoNotRetry ( error : Error )
case failureRetryAfterDelay ( retryDelay : TimeInterval )
case failureRetryWithoutDelay
// T h i s o n l y a p p l i e s t o f e t c h e s .
case unknownItem
}
private class func outcomeForCloudKitError ( error : Error ? ,
remainingRetries : Int ,
label : String ) -> APIOutcome {
if let error = error as ? CKError {
if error . code = = CKError . unknownItem {
// T h i s i s n o t a l w a y s a n e r r o r f o r o u r p u r p o s e s .
Logger . verbose ( " \( label ) unknown item. " )
return . unknownItem
}
Logger . error ( " \( label ) failed: \( error ) " )
if remainingRetries < 1 {
Logger . verbose ( " \( label ) no more retries. " )
return . failureDoNotRetry ( error : error )
}
if #available ( iOS 11 , * ) {
if error . code = = CKError . serverResponseLost {
Logger . verbose ( " \( label ) retry without delay. " )
return . failureRetryWithoutDelay
}
}
switch error {
case CKError . requestRateLimited , CKError . serviceUnavailable , CKError . zoneBusy :
let retryDelay = error . retryAfterSeconds ? ? 3.0
Logger . verbose ( " \( label ) retry with delay: \( retryDelay ) . " )
return . failureRetryAfterDelay ( retryDelay : retryDelay )
case CKError . networkFailure :
Logger . verbose ( " \( label ) retry without delay. " )
return . failureRetryWithoutDelay
default :
Logger . verbose ( " \( label ) unknown CKError. " )
return . failureDoNotRetry ( error : error )
}
} else if let error = error {
Logger . error ( " \( label ) failed: \( error ) " )
if remainingRetries < 1 {
Logger . verbose ( " \( label ) no more retries. " )
return . failureDoNotRetry ( error : error )
}
Logger . verbose ( " \( label ) unknown error. " )
return . failureDoNotRetry ( error : error )
} else {
Logger . info ( " \( label ) succeeded. " )
return . success
}
}
// MARK: -
@objc
public class func setup ( ) {
cancelAllLongLivedOperations ( )
}
private class func cancelAllLongLivedOperations ( ) {
// T h e s e A P I s a r e o n l y a v a i l a b l e i n i O S 9 . 3 a n d l a t e r .
guard #available ( iOS 9.3 , * ) else {
return
}
let container = CKContainer . default ( )
container . fetchAllLongLivedOperationIDs { ( operationIds , error ) in
if let error = error {
Logger . error ( " Could not get all long lived operations: \( error ) " )
return
}
guard let operationIds = operationIds else {
Logger . error ( " No operation ids. " )
return
}
for operationId in operationIds {
container . fetchLongLivedOperation ( withID : operationId , completionHandler : { ( operation , error ) in
if let error = error {
Logger . error ( " Could not get long lived operation [ \( operationId ) ]: \( error ) " )
return
}
guard let operation = operation else {
Logger . error ( " No operation. " )
return
}
operation . cancel ( )
} )
}
}
}
}