Retired Document
Important: This document may not represent best practices for current development. Links to downloads and other resources may no longer be valid.
Shared/APLCloudManager.m
/* |
Copyright (C) 2017 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
This contains all the CloudKit functions used by this sample. |
*/ |
#import "APLCloudManager.h" |
NSString * const kPhotoRecordType = @"PhotoRecord"; // our CKRecord type |
NSString * const kPhotoAsset = @"PhotoAsset"; // CKAsset |
NSString * const kPhotoTitle = @"PhotoTitle"; // NSString |
NSString * const kPhotoDate = @"PhotoDate"; // NSDate |
NSString * const kPhotoLocation = @"PhotoLocation"; // CLLocation |
NSString * const kUpdateContentWithNotification = @"UpdateContentWithNotification"; |
double kNearMeDistance = 5; // the distance (in kilometers) for searching photos near the user's location |
#pragma mark Error Logging |
void CloudKitErrorLog(int lineNumber, NSString *functionName, NSError *error); |
// generic, reusable utility routine for reporting possible CloudKit errors |
void CloudKitErrorLog(int lineNumber, NSString *functionName, NSError *error) |
{ |
if (error != noErr) |
{ |
NSMutableString *message = [NSMutableString stringWithFormat:@"\n\nAPLCloudManager ERROR [%@:%ld] ", error.domain, (long)error.code]; |
if (error.localizedDescription != nil) |
{ |
[message appendFormat:@"%@", error.localizedDescription]; |
} |
if (error.localizedFailureReason != nil) |
{ |
[message appendFormat:@", %@", error.localizedFailureReason]; |
} |
if (error.userInfo[NSUnderlyingErrorKey] != nil) |
{ |
[message appendFormat:@", %@", error.userInfo[NSUnderlyingErrorKey]]; |
} |
if (error.localizedRecoverySuggestion != nil) |
{ |
[message appendFormat:@", %@", error.localizedRecoverySuggestion]; |
} |
[message appendFormat:@" - %@%d\n", functionName, lineNumber]; |
NSLog(@"%@", message); |
} |
} |
#pragma mark - |
@interface APLCloudManager () |
@property (readonly) CKContainer *container; |
@property (readonly) CKDatabase *publicDatabase; |
@property (readonly) CKRecordID *userRecordID; |
@property (assign) CKApplicationPermissionStatus applicationPermissionStatus; // cached so we don't have to keep asking permission status |
// used for marking notifications as "read", this token tells the server what portions of the records to fetch and return to your app |
@property (nonatomic, strong) CKServerChangeToken *serverChangeToken; |
@property (assign) NSUInteger numberAuthenticationAttempts; |
@end |
#pragma mark - |
@implementation APLCloudManager |
+ (NSString *)PhotoRecordType { return kPhotoRecordType; } |
+ (NSString *)PhotoTitleAttribute { return kPhotoTitle; } |
+ (NSString *)PhotoAssetAttribute { return kPhotoAsset; } |
+ (NSString *)PhotoDateAttribute { return kPhotoDate; } |
+ (NSString *)PhotoLocationAttribute { return kPhotoLocation; } |
+ (NSString *)UpdateContentWithNotification { return kUpdateContentWithNotification; } |
// ------------------------------------------------------------------------------- |
// singleton class |
// ------------------------------------------------------------------------------- |
+ (APLCloudManager *)sharedInstance:(NSString *)containerID |
{ |
static APLCloudManager *cloudManager; // our singleton cloud manager controller |
static dispatch_once_t onceToken; |
dispatch_once(&onceToken, ^{ |
cloudManager = [[APLCloudManager alloc] initWithContainerIdentifier:containerID]; |
}); |
return cloudManager; |
} |
- (instancetype)init { |
NSAssert(NO, @"Invalid use of init; use initWithContainerIdentifier to create APLCloudManager"); |
return [self init]; |
} |
- (instancetype)initWithContainerIdentifier:(NSString *)containerIdentifier |
{ |
self = [super init]; |
if (self != nil) |
{ |
_container = [CKContainer containerWithIdentifier:containerIdentifier]; |
_publicDatabase = _container.publicCloudDatabase; |
_applicationPermissionStatus = CKApplicationPermissionStatusInitialState; |
[self checkAccountAvailable:^(BOOL available) { |
if (available) |
{ |
[self subscribe]; |
} |
}]; |
[self updateUserLogin:^() { |
// insert completion code here |
}]; |
// listen for account changes (logging in or out) |
[[NSNotificationCenter defaultCenter] addObserverForName:CKAccountChangedNotification |
object:nil |
queue:nil // use the current queue |
usingBlock:^(NSNotification *gatherNotification) |
{ |
// reset our permission status and check for it again |
_applicationPermissionStatus = CKApplicationPermissionStatusInitialState; |
[self checkAccountAvailable:^(BOOL available) { |
if (available) |
{ |
[self subscribe]; |
} |
}]; |
// find out about our logged in user (in case it changed) |
[self updateUserLogin:^() { |
// call our delegate to inform them of log in change |
[self.delegate userLoginChanged]; |
}]; |
}]; |
} |
return self; |
} |
// returns YES if the cloud service is available (user is logged into iCloud and has iCloud Drive turned on) |
- (void)cloudServiceAvailable:(void (^)(BOOL available))completionHandler |
{ |
__block BOOL serviceAvailable = NO; |
if (self.container != nil && self.publicDatabase != nil) |
{ |
/* |
Use this method to determine the extra capabilities granted to your app by the user. |
If your app has not yet requested a specific permission, calling this method may yield the value CKApplicationPermissionStatusInitialState for the permission. |
When that value is returned, we call the requestApplicationPermission:completionHandler: method to request the permission from the user. |
*/ |
[self.container statusForApplicationPermission:CKApplicationPermissionUserDiscoverability |
completionHandler:^(CKApplicationPermissionStatus appPermissionStatus, NSError *statusError) { |
//CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), statusError); |
/* If this app has not yet requested a specific permission, |
calling this method may yield the value CKApplicationPermissionStatusInitialState. |
*/ |
if (appPermissionStatus == CKApplicationPermissionStatusInitialState) |
{ |
// We have not requested access yet, call the requestApplicationPermission to request the permission from the user. |
[self.container requestApplicationPermission:CKApplicationPermissionUserDiscoverability |
completionHandler:^(CKApplicationPermissionStatus retryPermissionStatus, NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
serviceAvailable = (retryPermissionStatus != CKApplicationPermissionStatusCouldNotComplete); |
/* If an error occurred when getting the application permission status, |
we can consult the corresponding NSError for more details. |
*/ |
}]; |
} |
else |
{ |
/* Possible ways to get "CKApplicationPermissionStatusCouldNotComplete" error: |
1. if the user is not logged into iCloud or does not have iCloud Drive turned on. |
2. the client is being rate limited |
*/ |
/* If an error occurred when getting the application permission status, |
we can consult the corresponding NSError for more details. |
*/ |
if (appPermissionStatus == CKApplicationPermissionStatusCouldNotComplete) |
{ |
/* An error occurred when getting the application permission status, so consult the corresponding NSError. |
On CKErrorServiceUnavailable or CKErrorRequestRateLimited errors: |
the userInfo dictionary may contain a NSNumber instance that specifies the period of time in seconds after |
which we may retry the request. So here we will try again. |
*/ |
if (statusError.code == CKErrorServiceUnavailable || statusError.code == CKErrorRequestRateLimited) |
{ |
NSNumber *retryAfter = statusError.userInfo[CKErrorRetryAfterKey] ? : @3; // try again after 3 seconds if we don't have a retry hint |
NSLog(@"Error: %@. Recoverable, retry after %@ seconds", [statusError description], retryAfter); |
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter.intValue * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ |
[self.container statusForApplicationPermission:CKApplicationPermissionUserDiscoverability |
completionHandler:^(CKApplicationPermissionStatus retryAppPermissionStatus, NSError *retryError) { |
serviceAvailable = (retryAppPermissionStatus != CKApplicationPermissionStatusCouldNotComplete); |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^{ |
completionHandler(serviceAvailable); |
}); |
}]; |
}); |
} |
} |
else |
{ |
serviceAvailable = (appPermissionStatus != CKApplicationPermissionStatusCouldNotComplete); |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^{ |
completionHandler(serviceAvailable); |
}); |
} |
} |
}]; |
} |
} |
#pragma mark - Fetching |
// fetch for a single record by record ID |
// |
- (void)fetchRecordWithID:(CKRecordID *)recordID completionHandler:(void (^)(CKRecord *record, NSError *error))completionHandler |
{ |
[self.publicDatabase fetchRecordWithID:recordID completionHandler:^(CKRecord *record, NSError *error) { |
// report any error but "record not found" |
if (error.code != CKErrorUnknownItem) |
{ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
} |
// call the completion handler on the main queue |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
completionHandler(record, error); |
}); |
}]; |
} |
// fetch for multiple records |
// |
// We submit our CKQuery to a CKQueryOperation. The CKQueryOperation has the concept of cursor and a resultsLimit. |
// This will allow you to bundle your query results into chunks, avoiding very long query times. |
// In our case we limit to 20 at a time, and keep refetching more if available. |
// |
#define kResultsLimit 20 |
- (void)fetchRecords:(void (^)(NSArray *records, NSError *error))completionHandler |
{ |
NSPredicate *truePredicate = [NSPredicate predicateWithValue:YES]; // find "all" records |
CKQuery *query = [[CKQuery alloc] initWithRecordType:kPhotoRecordType predicate:truePredicate]; |
// note: if we want to sort by creationDate, use this: (the Dashboard needs to set this field as sortable) |
// query.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]]; |
// |
// but in our case we sort alphabetically by the "kPhotoTitle" field |
query.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:kPhotoTitle ascending:YES]]; |
CKQueryOperation *queryOperation = [[CKQueryOperation alloc] initWithQuery:query]; |
queryOperation.resultsLimit = kResultsLimit; |
queryOperation.qualityOfService = NSQualityOfServiceUserInteractive; |
// request these attributes (important to get all attributes in favor if our APLDetailViewController) |
queryOperation.desiredKeys = @[kPhotoTitle, kPhotoAsset, kPhotoDate, kPhotoLocation]; |
NSMutableArray *results = [[NSMutableArray alloc] init]; |
// defined our fetched record block so we can add each found record to our results array |
__block void (^recordFetchedBlock)(CKRecord *) = ^(CKRecord *record) { |
// found a record from the query |
[results addObject:record]; |
}; |
queryOperation.recordFetchedBlock = recordFetchedBlock; |
// define and add our completion block to fetch possibly more records, or finish by calling our caller's completion block |
__weak __block void (^block_self)(CKQueryCursor *, NSError *); |
void (^myCompletionBlock)(CKQueryCursor *, NSError *) = [^(CKQueryCursor *cursor, NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
if (cursor != nil) |
{ |
// there's more fetching to do |
CKQueryOperation *continuedQueryOperation = [[CKQueryOperation alloc] initWithCursor:cursor]; |
continuedQueryOperation.queryCompletionBlock = block_self; |
continuedQueryOperation.recordFetchedBlock = recordFetchedBlock; |
[self.publicDatabase addOperation:continuedQueryOperation]; |
} |
else |
{ |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
// call the completion handler |
completionHandler(results, error); |
}); |
} |
} copy]; |
block_self = myCompletionBlock; |
queryOperation.queryCompletionBlock = block_self; |
[self.publicDatabase addOperation:queryOperation]; |
} |
- (void)fetchRecordsWithPredicate:(NSPredicate *)predicate completionHandler:(void (^)(NSArray *records, NSError *error))completionHandler |
{ |
CKQuery *query = [[CKQuery alloc] initWithRecordType:kPhotoRecordType predicate:predicate]; |
// note: if we want to sort by creationDate, use this: (the Dashboard needs to set this field as sortable) |
// query.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]]; |
// |
// but in our case we sort alphabetically by the "kPhotoTitle" field |
query.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:kPhotoTitle ascending:YES]]; |
CKQueryOperation *photosNearQueryOperation = [[CKQueryOperation alloc] initWithQuery:query]; |
NSArray *desiredKeys = @[kPhotoTitle, kPhotoAsset, kPhotoDate, kPhotoLocation]; |
photosNearQueryOperation.desiredKeys = desiredKeys; |
NSMutableArray *results = [[NSMutableArray alloc] init]; |
// defined our fetched record block so we can add records to our results array |
__block void (^recordFetchedBlock)(CKRecord *) = ^(CKRecord *record) { |
// found a record |
[results addObject:record]; |
}; |
photosNearQueryOperation.recordFetchedBlock = recordFetchedBlock; |
// define and add our completion block to fetch possibly more records, or finish by calling our caller's completion block |
__weak __block void (^block_self)(CKQueryCursor *, NSError *); |
void (^myCompletionBlock)(CKQueryCursor *, NSError *) = [^(CKQueryCursor *cursor, NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
if (cursor != nil) |
{ |
// there's more fetching to do |
CKQueryOperation *continuedQueryOperation = [[CKQueryOperation alloc] initWithCursor:cursor]; |
continuedQueryOperation.desiredKeys = desiredKeys; |
continuedQueryOperation.queryCompletionBlock = block_self; |
continuedQueryOperation.recordFetchedBlock = recordFetchedBlock; |
[self.publicDatabase addOperation:continuedQueryOperation]; |
} |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
completionHandler(results, error); |
}); |
} copy]; |
block_self = myCompletionBlock; |
photosNearQueryOperation.queryCompletionBlock = block_self; |
[self.publicDatabase addOperation:photosNearQueryOperation]; |
} |
- (BOOL)isMyRecord:(CKRecordID *)recordID |
{ |
return ([recordID.recordName isEqual:CKCurrentUserDefaultName] && self.userRecordID != nil); |
} |
#pragma mark - Deleting and Saving |
- (void)deleteRecordWithID:(CKRecordID *)recordID completionHandler:(void (^)(CKRecordID *recordID, NSError *error))completionHandler |
{ |
[self.publicDatabase deleteRecordWithID:recordID completionHandler:^(CKRecordID *deletedRecordID, NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
completionHandler(deletedRecordID, error); |
}); |
}]; |
} |
- (void)deleteRecordsWithIDs:(NSArray *)recordIDs completionHandler:(void (^)(NSArray *deletedRecordIDs, NSError *error))completionHandler |
{ |
// we use CKModifyRecordsOperation to delete multiple records |
CKModifyRecordsOperation *operation = |
[[CKModifyRecordsOperation alloc] initWithRecordsToSave:nil recordIDsToDelete:recordIDs]; |
operation.savePolicy = CKRecordSaveIfServerRecordUnchanged; |
operation.queuePriority = NSOperationQueuePriorityHigh; |
// The following Quality of Service (QoS) is used to indicate to the system the nature and importance of this work. |
// Higher QoS classes receive more resources than lower ones during resource contention. |
// |
operation.qualityOfService = NSQualityOfServiceUserInitiated; |
// add the completion for the entire delete operation |
operation.modifyRecordsCompletionBlock = ^(NSArray *savedRecords, NSArray *deletedRecordIDs, NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
completionHandler(deletedRecordIDs, error); |
}); |
}; |
// start the operation |
[self.publicDatabase addOperation:operation]; |
} |
- (void)saveRecord:(CKRecord *)record completionHandler:(void (^)(CKRecord *savedRecord, NSError *error))completionHandler |
{ |
[self.publicDatabase saveRecord:record completionHandler:^(CKRecord *savedRecord, NSError *error) { |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
completionHandler(savedRecord, error); |
}); |
}); |
}]; |
} |
- (void)modifyRecord:(CKRecord *)recordToModify completionHandler:(void (^)(CKRecord *record, NSError *error))completionHandler |
{ |
// we use CKModifyRecordsOperation to modify records (in this case one record) |
CKModifyRecordsOperation *operation = |
[[CKModifyRecordsOperation alloc] initWithRecordsToSave:@[recordToModify] recordIDsToDelete:nil]; |
operation.savePolicy = CKRecordSaveIfServerRecordUnchanged; |
operation.queuePriority = NSOperationQueuePriorityHigh; |
// The following Quality of Service (QoS) is used to indicate to the system the nature and importance of this work. |
// Higher QoS classes receive more resources than lower ones during resource contention. |
// |
operation.qualityOfService = NSQualityOfServiceUserInitiated; |
// report the progress on a per record basis |
operation.perRecordProgressBlock = ^(CKRecord *record, double progress) { |
//NSLog(@"modifying record: %.0f%% complete", progress*100); |
}; |
// completed completion block for the once modified record |
operation.modifyRecordsCompletionBlock = ^(NSArray *savedRecords, NSArray *deletedRecordIDs, NSError *operationError) { |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
if (operationError != nil) |
{ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), operationError); |
} |
// We are checking for only one modified record here. |
CKRecord *modifiedRecord = (savedRecords.count == 1) ? savedRecords[0] : nil; |
// Call our completion with the saved record (or nil if failed). |
completionHandler(modifiedRecord, operationError); |
}); |
}; |
// callback for each record modified (here we only modify one record but... |
// for illustration purposes we show how to deal with modifying multiple records |
// |
operation.perRecordCompletionBlock = ^(CKRecord *record, NSError *error) { |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
if (error != nil) |
{ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
if (error.code == CKErrorServerRecordChanged) |
{ |
// CKRecordChangedErrorAncestorRecordKey: |
// Key to the original CKRecord that you used as the basis for making your changes. |
CKRecord *ancestorRecord = error.userInfo[CKRecordChangedErrorAncestorRecordKey]; |
// CKRecordChangedErrorServerRecordKey: |
// Key to the CKRecord that was found on the server. Use this record as the basis for merging your changes. |
CKRecord *serverRecord = error.userInfo[CKRecordChangedErrorServerRecordKey]; |
// CKRecordChangedErrorClientRecordKey: |
// Key to the CKRecord that you tried to save. |
// This record is based on the record in the CKRecordChangedErrorAncestorRecordKey key but contains the additional changes you made. |
CKRecord *clientRecord = error.userInfo[CKRecordChangedErrorClientRecordKey]; |
NSAssert(ancestorRecord != nil || serverRecord != nil || clientRecord != nil, |
@"Error CKModifyRecordsOperation, can't obtain ancestor, server or client records to resolve conflict."); |
// important to use the server's record as a basis for our changes, |
// apply our current record to the server's version |
// |
serverRecord[kPhotoTitle] = clientRecord[kPhotoTitle]; |
serverRecord[kPhotoAsset] = clientRecord[kPhotoAsset]; |
serverRecord[kPhotoDate] = clientRecord[kPhotoDate]; |
serverRecord[kPhotoLocation] = clientRecord[kPhotoLocation]; |
// save the newer record |
[self.publicDatabase saveRecord:serverRecord completionHandler:^(CKRecord *savedRecord, NSError *saveError) { |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
// success, return the saved record |
completionHandler(savedRecord, saveError); |
}); |
}]; |
} |
} |
}); |
}; |
// start the modify operation |
[self.publicDatabase addOperation:operation]; |
} |
#pragma mark - Photo Utilities |
// this will create a sized down/compressed cached image in the caches folder |
- (NSURL *)createCachedImageFromImage: |
#if TARGET_OS_IPHONE |
(UIImage *)image |
#else |
(NSImage *)image |
#endif |
{ |
NSURL *resultURL = nil; |
CGSize cacheImageSize = {512, 512}; // the size we want for the stored image CKAsset |
if (image != nil) |
{ |
if (image.size.width > image.size.height) |
{ |
cacheImageSize.height = round(cacheImageSize.width * image.size.height / image.size.width); |
} |
else |
{ |
cacheImageSize.width = round(cacheImageSize.height * image.size.width / image.size.height); |
} |
NSData *imageData; |
#if TARGET_OS_IPHONE |
UIGraphicsBeginImageContext(cacheImageSize); |
[image drawInRect:CGRectMake(0, 0, cacheImageSize.width, cacheImageSize.height)]; |
imageData = UIImageJPEGRepresentation(UIGraphicsGetImageFromCurrentImageContext(), 0.75); |
UIGraphicsEndImageContext(); |
#else |
imageData = image.TIFFRepresentation; |
NSBitmapImageRep *imageRep = [NSBitmapImageRep imageRepWithData:imageData]; |
NSNumber *compressionFactor = @0.9f; |
NSDictionary *imageProps = @{NSImageCompressionFactor: compressionFactor}; |
imageData = [imageRep representationUsingType:NSJPEGFileType properties:imageProps]; |
#endif |
// write the image out to a cache file |
NSURL *cachesDirectory = [[NSFileManager defaultManager] URLForDirectory:NSCachesDirectory |
inDomain:NSUserDomainMask |
appropriateForURL:nil |
create:YES |
error:nil]; |
NSString *temporaryName = [[NSUUID UUID].UUIDString stringByAppendingPathExtension:@"jpeg"]; |
resultURL = [cachesDirectory URLByAppendingPathComponent:temporaryName]; |
[imageData writeToURL:resultURL atomically:YES]; |
} |
return resultURL; |
} |
- (void)addNewRecord:(NSString *)title date:(NSDate *)date location:(CLLocation *)location completionHandler:(void (^)(CKRecord *record, NSError *error))completionHandler |
{ |
CKRecord *newRecord = [[CKRecord alloc] initWithRecordType:[APLCloudManager PhotoRecordType]]; |
newRecord[[APLCloudManager PhotoTitleAttribute]] = title; |
newRecord[[APLCloudManager PhotoDateAttribute]] = date; |
newRecord[[APLCloudManager PhotoLocationAttribute]] = location; |
[self saveRecord:newRecord completionHandler:^(CKRecord *record, NSError *error) { |
if (error != nil) |
{ |
// if there are no records defined in iCloud dashboard you will get this error: |
/* error 9 { |
NSDebugDescription = "CKInternalErrorDomain: 1004"; |
NSLocalizedDescription = "Account couldn't get container scoped user id, no underlying error received" |
*/ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
} |
completionHandler(record, error); |
}]; |
} |
- (void)addRecordWithImage: |
#if TARGET_OS_IPHONE |
(UIImage *)image |
#else |
(NSImage *)image |
#endif |
title:(NSString *)title |
date:(NSDate *)date |
location:(CLLocation *)location |
completionHandler:(void (^)(CKRecord *record, NSError *error))completionHandler |
{ |
CKRecord *newRecord = [[CKRecord alloc] initWithRecordType:[APLCloudManager PhotoRecordType]]; |
newRecord[[APLCloudManager PhotoTitleAttribute]] = title; |
newRecord[[APLCloudManager PhotoDateAttribute]] = date; |
newRecord[[APLCloudManager PhotoLocationAttribute]] = location; |
// this will create a sized down/compressed cached image in the caches folder |
NSURL *imageURL = [self createCachedImageFromImage:image]; |
if (imageURL != nil) |
{ |
CKAsset *asset = [[CKAsset alloc] initWithFileURL:imageURL]; |
newRecord[[APLCloudManager PhotoAssetAttribute]] = asset; |
} |
[self saveRecord:newRecord completionHandler:^(CKRecord *record, NSError *error) { |
if (error != nil) |
{ |
// if there are no records defined in iCloud dashboard you will get this error: |
/* error 9 { |
NSDebugDescription = "CKInternalErrorDomain: 1004"; |
NSLocalizedDescription = "Account couldn't get container scoped user id, no underlying error received" |
*/ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
} |
completionHandler(record, error); |
}]; |
} |
#pragma mark - Subscriptions and Notifications |
- (void)saveSubscription:(CKSubscription *)subscriptionInfo completionHandler:(void (^)(NSError *error))completionHandler |
{ |
CKModifySubscriptionsOperation *modifyOperation = [[CKModifySubscriptionsOperation alloc] init]; |
modifyOperation.subscriptionsToSave = @[subscriptionInfo]; |
modifyOperation.modifySubscriptionsCompletionBlock = ^(NSArray *savedSubscriptions, NSArray *deletedSubscriptionIDs, NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
if (error.code == CKErrorServerRejectedRequest) |
{ |
// save subscription request rejected! |
// trying to save a subscribution (subscribe) failed (probably because we already have a subscription saved) |
// |
// this is likely due to the fact that the app was deleted and reinstalled to the device, |
// so assume we have a subscription already registed with the server |
// |
} |
else if (error.code == CKErrorNotAuthenticated) |
{ |
// could not subscribe (not authenticated) |
//NSLog(@"User not authenticated (could not subscribe to record changes)"); |
} |
else if (error.code == CKErrorPartialFailure) |
{ |
// some items failed, but the operation succeeded overall |
NSLog(@"\r\rpartial errors in saving our subscription,\r(some items failed, but the operation succeeded overall).\rUnable to save subscriptions.\r"); |
} |
// back on the main queue, store as a default and call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
completionHandler(error); // we are done |
}); |
}; |
[self.publicDatabase addOperation:modifyOperation]; |
} |
- (void)startSubscriptions |
{ |
// subscribe to deletion, update and creation of our record type |
// |
// Note: for each user a separate CKSubscription will be saved to the cloud for their account |
// |
NSPredicate *truePredicate = [NSPredicate predicateWithValue:YES]; // we are interested in "all" changes to kRecordType |
// 1) subscribe to record creation, updates and deletions |
__block CKQuerySubscription *itemSubscription = |
[[CKQuerySubscription alloc] initWithRecordType:kPhotoRecordType |
predicate:truePredicate |
options:CKQuerySubscriptionOptionsFiresOnRecordCreation | CKQuerySubscriptionOptionsFiresOnRecordUpdate | |
CKQuerySubscriptionOptionsFiresOnRecordDeletion]; |
CKNotificationInfo *notification = [[CKNotificationInfo alloc] init]; |
// 2) set the notification content: |
// |
// note: if you don't set "alertBody", "soundName" or "shouldBadge", it will make the notification a priority, sent at an opportune time |
// |
notification.alertBody = NSLocalizedString(@"Notif alert body", nil); |
// 3) allows the action to launch the app if it’s not running. Once launched, the notifications will be delivered, |
// and the app will be given some background time to process them. |
// |
// Indicates that the notification should be sent with the "content-available" flag |
// to allow for background downloads in the application. Default value is NO. |
// |
notification.shouldSendContentAvailable = YES; |
// 4) optional |
notification.soundName = @"Hero.aiff"; // or default: UILocalNotificationDefaultSoundName |
// below identifies an image in your bundle to be shown as an alternate launch image |
// when launching from the notification, this is used on this case: |
// 1. app is launched |
// 2. device is turned off and on again |
// 3. change CKRecord on another device |
// 4. notif arrives, tap open or tap banner and the launch image (all pink) shows |
// |
//notification.alertLaunchImage = @"<your launch image>.png"; |
// 5) a list of keys from the matching record to include in the notification payload, |
// here are are only interested in the title (kPhotoAsset can't be a desired key, unsupported) |
// |
notification.desiredKeys = @[kPhotoTitle]; |
// set our CKNotificationInfo to our CKSubscription |
itemSubscription.notificationInfo = notification; |
// save our subscription, |
// note: that if saving multiple subscriptions, they should be saved in succession, and not independently |
// |
[self saveSubscription:itemSubscription completionHandler:^(NSError *error) { |
//.. |
}]; |
} |
- (void)subscribe |
{ |
// find any subscription saved on the server |
CKFetchSubscriptionsOperation *fetchSubscriptionsOperation = [CKFetchSubscriptionsOperation fetchAllSubscriptionsOperation]; |
fetchSubscriptionsOperation.fetchSubscriptionCompletionBlock = ^(NSDictionary *subscriptionsBySubscriptionID, NSError *operationError) { |
if (operationError != nil) |
{ |
// error in fetching our subscription |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), operationError); |
if (operationError.code == CKErrorNotAuthenticated) |
{ |
// try again after 3 seconds if we don't have a retry hint |
// |
NSNumber *retryAfter = operationError.userInfo[CKErrorRetryAfterKey] ? : @3; |
NSLog(@"Error: %@. Recoverable, retry after %@ seconds", operationError.description, retryAfter); |
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter.intValue * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ |
[self subscribe]; // call this method again to retry |
}); |
} |
else if (operationError.code == CKErrorBadContainer) |
{ |
// Un-provisioned or unauthorized container. Try provisioning the container before retrying the operation. |
} |
} |
else |
{ |
if (subscriptionsBySubscriptionID != nil && subscriptionsBySubscriptionID.count > 0) |
{ |
// found an existing subscription, not necessary to subscribe again |
} |
else |
{ |
// still no subscriptions found on the server, so save a new subscription |
// |
[self startSubscriptions]; |
} |
} |
}; |
[self.publicDatabase addOperation:fetchSubscriptionsOperation]; |
} |
- (void)unsubscribe |
{ |
CKFetchSubscriptionsOperation *fetchSubscriptionsOperation = [CKFetchSubscriptionsOperation fetchAllSubscriptionsOperation]; |
fetchSubscriptionsOperation.fetchSubscriptionCompletionBlock = ^(NSDictionary *subscriptionsBySubscriptionID, NSError *operationError) { |
if (operationError != nil) |
{ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), operationError); |
} |
else |
{ |
if (subscriptionsBySubscriptionID != nil && subscriptionsBySubscriptionID.count > 0) |
{ |
// we already have one or more CKSubscriptions registered with the server, |
// we want to modify our current subscription and delete the subscription ID from it |
// |
NSArray *subscriptionIDs = [subscriptionsBySubscriptionID allKeys]; |
CKModifySubscriptionsOperation *modifyOperation = [[CKModifySubscriptionsOperation alloc] init]; |
modifyOperation.subscriptionIDsToDelete = subscriptionIDs; |
modifyOperation.modifySubscriptionsCompletionBlock = ^(NSArray *savedSubscriptions, NSArray *deletedSubscriptionIDs, NSError *error) { |
if (error != nil) |
{ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
} |
else |
{ |
// successfully unsubscribed |
} |
}; |
[self.publicDatabase addOperation:modifyOperation]; |
} |
else |
{ |
// no subscriptions found, unsubscribe not necessary |
} |
} |
}; |
[self.publicDatabase addOperation:fetchSubscriptionsOperation]; |
} |
- (void)processNotifications |
{ |
// note this is called recusrively for processing additional pending notifications |
[self processNotifications:self.serverChangeToken]; |
} |
- (void)processNotifications:(CKServerChangeToken *)serverChangeToken |
{ |
// each item in the notification queue need to be marked as "read" so next time we won't be concerned about them |
// |
__block NSMutableArray *itemsToMarkAsRead = [NSMutableArray array]; |
// this operation will fetch all notification changes, |
// if a change anchor from a previous CKFetchNotificationChangesOperation is passed in, |
// only the notifications that have changed since that anchor will be fetched. |
// |
CKFetchNotificationChangesOperation *fetchChangesOperation = |
[[CKFetchNotificationChangesOperation alloc] initWithPreviousServerChangeToken:self.serverChangeToken]; |
__weak CKFetchNotificationChangesOperation *weakFetchChangesOperation = fetchChangesOperation; |
fetchChangesOperation.fetchNotificationChangesCompletionBlock = ^(CKServerChangeToken *newerServerChangeToken, NSError *operationError) { |
if (operationError != nil) |
{ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), operationError); |
} |
else |
{ |
// If "moreComing" is set then the server wasn't able to return all the changes in this response, |
// another CKFetchNotificationChangesOperation operation should be run with the updated serverChangeToken token from this operation. |
// |
if (weakFetchChangesOperation.moreComing) |
{ |
[self processNotifications:newerServerChangeToken]; |
} |
else |
{ |
_serverChangeToken = newerServerChangeToken; |
} |
} |
}; |
// this block processes a single push notification |
fetchChangesOperation.notificationChangedBlock = ^(CKNotification *notification) { |
if (notification.notificationType != CKNotificationTypeReadNotification) |
{ |
CKNotificationType notificationType = notification.notificationType; |
// send only query notifications to our client UI |
if (notificationType == CKNotificationTypeQuery) |
{ |
// post the custom notification on the main queue (to any interested view controller) |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
[[NSNotificationCenter defaultCenter] postNotificationName:kUpdateContentWithNotification object:notification]; |
}); |
} |
[itemsToMarkAsRead addObject:notification.notificationID]; // add the CKQueryNotification's notif ID to our array so that it can be marked as read |
} |
}; |
// this block is executed after all requested notifications are fetched |
fetchChangesOperation.completionBlock = ^{ |
//NSLog(@"found %lu items in the change notif queue", (unsigned long)array.count); |
// mark all of them as "read" |
CKMarkNotificationsReadOperation *markNotifsReadOperation = [[CKMarkNotificationsReadOperation alloc] initWithNotificationIDsToMarkRead:itemsToMarkAsRead]; |
markNotifsReadOperation.markNotificationsReadCompletionBlock = ^ (NSArray *notificationIDsMarkedRead, NSError *operationError) { |
if (operationError != nil) |
{ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), operationError); |
//NSLog(@"Unable to mark notifs as read: %@", operationError); |
} |
else |
{ |
// finished marking the notifications as "read" |
//NSLog(@"items marked as read = %lu", (unsigned long)notificationIDsMarkedRead.count); |
} |
}; |
[self.container addOperation:markNotifsReadOperation]; |
}; |
[self.container addOperation:fetchChangesOperation]; |
} |
#pragma mark - User Discoverability |
// returns YES if the user has logged into iCloud |
- (BOOL)userLoginIsValid |
{ |
return (self.userRecordID != nil); |
} |
// check the user account status (are we logged in?) |
- (void)checkAccountAvailable:(void (^)(BOOL available))completionHandler |
{ |
[self.container accountStatusWithCompletionHandler:^(CKAccountStatus accountStatus, NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
switch (accountStatus) { |
case CKAccountStatusCouldNotDetermine: |
// an error occurred when getting the account status, consult the corresponding NSError |
break; |
case CKAccountStatusRestricted: |
// Parental Controls / Device Management has denied access to iCloud account credentials |
break; |
case CKAccountStatusNoAccount: |
// no iCloud account is logged in on this device |
break; |
default: break; |
} |
// back on the main queue, call our completion handler with the available result |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
// note: accountStatus could be "CKAccountStatusAvailable", and at the same time there could be no network, |
// in this case the user should not be able to add, remove or modify photos |
// |
// (CKAccountStatusAvailable = The iCloud account credentials are available for this application) |
// |
_accountAvailable = (accountStatus == CKAccountStatusAvailable); |
completionHandler(self.accountAvailable); |
}); |
}]; |
} |
// Asks for discoverability permission from the user. |
// |
// This will bring up an alert: "Allow people using "CloudPhotos" to look you up by email?", |
// clicking "Don't Allow" will not make you discoverable. |
// |
// The first time you request a permission on any of the user’s devices, the user is prompted to grant or deny the request. |
// Once the user grants or denies a permission, subsequent requests for the same permission |
// (on the same or separate devices) do not prompt the user again. |
// |
- (void)requestDiscoverabilityPermission:(void (^)(BOOL discoverable)) completionHandler { |
if (self.applicationPermissionStatus == CKApplicationPermissionStatusGranted || |
self.applicationPermissionStatus == CKApplicationPermissionStatusDenied) |
{ |
// We already know our user's permission, so just call the completion handler back on the main queue. |
dispatch_async(dispatch_get_main_queue(), ^{ |
completionHandler(self.applicationPermissionStatus == CKApplicationPermissionStatusGranted); |
}); |
} |
else |
{ |
[self.container requestApplicationPermission:CKApplicationPermissionUserDiscoverability |
completionHandler:^(CKApplicationPermissionStatus applicationPermissionStatus, NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
if (error.code != CKErrorNetworkUnavailable && applicationPermissionStatus == CKApplicationPermissionStatusCouldNotComplete) |
{ |
// An error occurred when getting the application permission status, |
// (likely because we are logged out or iCloud Drive is off) so consult the corresponding NSError. |
// Try again after 3 seconds if we don't have a retry hint. |
// |
NSNumber *retryAfter = error.userInfo[CKErrorRetryAfterKey] ? : @3; |
if (self.numberAuthenticationAttempts < 3) // retry only 3 times before giving up |
{ |
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter.intValue * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ |
[self requestDiscoverabilityPermission:completionHandler]; // Call this method again to retry. |
_numberAuthenticationAttempts++; |
}); |
} |
else |
{ |
// Four attempts have been made already, we are giving up here. |
_numberAuthenticationAttempts = 0; |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); // Report the error for one last time. |
NSLog(@"\n\nGIVING UP: on requesting user discovering permissions\n"); |
} |
} |
else |
{ |
_applicationPermissionStatus = applicationPermissionStatus; |
} |
// Back on the main queue, call our completion handler. |
dispatch_async(dispatch_get_main_queue(), ^{ |
completionHandler(self.applicationPermissionStatus == CKApplicationPermissionStatusGranted); |
}); |
}]; |
} |
} |
// obtain information on all users in our Address Book |
// how this is called: |
// |
// [self fetchAllUsers:^(NSArray *userIdentities) { }]; |
// |
- (void)fetchAllUsers:(void (^)(NSArray *userIdentities))completionHandler |
{ |
// find all discoverable users in the device's address book |
// |
__block NSMutableArray *allUsers = [NSMutableArray array]; |
CKDiscoverAllUserIdentitiesOperation *op = [[CKDiscoverAllUserIdentitiesOperation alloc] init]; |
op.queuePriority = NSOperationQueuePriorityNormal; |
op.userIdentityDiscoveredBlock = ^(CKUserIdentity *identity) { |
[allUsers addObject:identity]; |
}; |
op.discoverAllUserIdentitiesCompletionBlock = ^(NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
completionHandler(allUsers); |
}); |
}; |
[self.container addOperation:op]; |
// or directly without NSOperation |
/*[self.container discoverAllContactUserInfosWithCompletionHandler:^(NSArray *userInfos, NSError *error) { |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^{ |
}); |
}];*/ |
} |
// obtain the current logged in user's CKRecordID |
// |
- (void)fetchLoggedInUserRecord:(void (^)(CKRecordID *recordID))completionHandler |
{ |
if (self.userRecordID != nil) // don't request it again, if we already have the user's record |
{ |
completionHandler(self.userRecordID); |
} |
else |
{ |
[self requestDiscoverabilityPermission:^(BOOL discoverable) { |
if (discoverable) |
{ |
[self.container fetchUserRecordIDWithCompletionHandler:^(CKRecordID *recordID, NSError *error) { |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
_userRecordID = recordID; |
completionHandler(recordID); // invoke our caller's completion handler indicating we are done |
}); |
}]; |
} |
else |
{ |
// can't discover user, return nil user recordID back on the main queue |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
// invoke our caller's completion handler with a nil user record ID, indicating we are done |
completionHandler(nil); |
}); |
} |
}]; |
} |
} |
// Discover the given CKRecordID's user's info with CKDiscoverUserInfosOperation, |
// return in its completion handler the last name and first name, if possible. |
// Users of an app must opt in to discoverability before their user records can be accessed. |
// |
- (void)fetchUserNameFromRecordID:(CKRecordID *)recordID completionHandler:(void (^)(NSString *familyName))completionHandler |
{ |
NSAssert(recordID != nil, @"Error fetchUserNameFromRecordID, incoming recordID is nil"); |
// first find our own login user recordID |
[self fetchLoggedInUserRecord:^(CKRecordID *loggedInUserRecordID) { |
CKRecordID *recordIDToUse = nil; |
// we found our login user recordID, is it our photo? |
if ([self isMyRecord:recordID]) |
{ |
// we own this record, so look up our user name using our login recordID |
recordIDToUse = loggedInUserRecordID; |
} |
else |
{ |
// this recordID is owned by another user, find its user info using the incoming "recordID" directly |
recordIDToUse = recordID; |
} |
if (recordIDToUse != nil) |
{ |
__block NSMutableArray *userIdentities = [NSMutableArray array]; |
CKUserIdentityLookupInfo *userLookupInfo = [[CKUserIdentityLookupInfo alloc] initWithUserRecordID:recordID]; |
CKDiscoverUserIdentitiesOperation *discoverOperation = [[CKDiscoverUserIdentitiesOperation alloc] initWithUserIdentityLookupInfos:@[userLookupInfo]]; |
// note this block may not be called if the user is logged out of iCloud |
discoverOperation.userIdentityDiscoveredBlock = ^(CKUserIdentity *identity, CKUserIdentityLookupInfo *lookupInfo) |
{ |
NSPersonNameComponents *nameComponents = [identity nameComponents]; |
[userIdentities addObject:nameComponents.familyName]; |
}; |
discoverOperation.discoverUserIdentitiesCompletionBlock = ^(NSError * _Nullable operationError) |
{ |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), operationError); |
// back on the main queue, call our completion handler with the results |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
// note by now, if userIdentities array is empty, it's likely the user is logged out of iCloud |
NSString *userName = NSLocalizedString(@"Undetermined Login Name", nil); |
if (userIdentities.count > 0) |
{ |
userName = userIdentities[0]; |
} |
completionHandler(userName); |
}); |
}; |
[self.container addOperation:discoverOperation]; |
} |
else |
{ |
// could not find our login user recordID (probably because we are logged out or the user are not discoverable) |
// report back with a generic name |
// |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
completionHandler(NSLocalizedString(@"Undetermined Login Name", nil)); |
}); |
} |
}]; |
} |
// used to update our user information (in case user logged out/in or with a different account), |
// typically you call this when the app becomes active from launch or from the background. |
// |
- (void)updateUserLogin:(void (^)(void))completionHandler |
{ |
// first ask for discoverability permission from the user |
[self requestDiscoverabilityPermission:^(BOOL discoverable) { |
// first obtain the CKRecordID of the logged in user (we use it to find the user's contact info) |
// |
[self.container fetchUserRecordIDWithCompletionHandler:^(CKRecordID *recordID, NSError *error) { |
if (error != nil) |
{ |
// no user information will be known at this time |
_userRecordID = nil; |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), error); |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
// no user information found, due to an error, invoke our caller's completion handler indicating we are done |
completionHandler(); |
}); |
} |
else |
{ |
_userRecordID = recordID; |
// retrieve info about the logged in user using it's CKRecordID |
[self.container discoverUserIdentityWithUserRecordID:recordID completionHandler:^(CKUserIdentity *userInfo, NSError *discoverError) { |
if (discoverError != nil) |
{ |
// if we get network failure error (4), we still get back a recordID, which means no access to CloudKit container |
CloudKitErrorLog(__LINE__, NSStringFromSelector(_cmd), discoverError); |
} |
else |
{ |
//NSLog(@"logged in as '%@ %@'", userInfo.nameComponents.givenName, userInfo.nameComponents.familyName); |
} |
// back on the main queue, call our completion handler |
dispatch_async(dispatch_get_main_queue(), ^(void) { |
completionHandler(); // invoke our caller's completion handler indicating we are done |
}); |
}]; |
} |
}]; |
}]; |
} |
@end |
Copyright © 2017 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2017-03-09