CloudCaptions/AAPLPostManager.m
/* |
Copyright (C) 2014 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
*/ |
@import CloudKit; |
#import "AAPLPostManager.h" |
#import "AAPLPost.h" |
typedef NS_ENUM(NSInteger, AAPLPostManagerErrorResponse) { |
AAPLPostManagerErrorIgnore, |
AAPLPostManagerErrorRetry, |
AAPLPostManagerErrorSuccess, |
}; |
@interface AAPLPostManager () |
@property (strong, atomic) void (^reloadBlock)(); |
@property (strong, atomic) AAPLPost *lastPostSeenOnServer; |
@property (strong, atomic) NSArray *tagArray; |
@property (strong, atomic) CKQueryCursor *postCursor; |
@property (strong, atomic) NSOperationQueue *fetchRecordQueue; // Allows for us to cancel loadBatch operation when the tag string has changed |
@property (strong, atomic) dispatch_queue_t updateCellArrayQueue; // Synchronous dispatch queue to synchronously add objects to postCells array |
@property (atomic) BOOL isLoadingBatch; // Flags when we're loading a batch so we don't try loading a second batch while this one is running |
@property (atomic) BOOL haveOldestPost; // Flags when we've loaded the earliest post |
@end |
#pragma mark - |
@implementation AAPLPostManager |
- (instancetype) initWithReloadHandler:(void (^)(void))reload |
{ |
self = [super init]; |
if (self != nil) |
{ |
_reloadBlock = reload; |
_postCells = [[NSMutableArray alloc] init]; |
// By setting up these queues, we're able to cancel all udates when the tag string changes |
_updateCellArrayQueue = dispatch_queue_create("UpdateCellQueue", DISPATCH_QUEUE_SERIAL); |
_fetchRecordQueue = [[NSOperationQueue alloc] init]; |
} |
return self; |
} |
- (void) resetWithTagString:(NSString *)tags |
{ |
// Reloads table with new tag settings |
// First, anything the table is updating with now is potentially invalid, cancel any current updates |
[self.fetchRecordQueue cancelAllOperations]; |
dispatch_sync(self.updateCellArrayQueue, ^{}); // This should only be filled with array add operations, best to just wait for it to finish |
// Resets the table to be empty |
self.postCells = [[NSMutableArray alloc] init]; |
self.lastPostSeenOnServer = nil; |
self.reloadBlock(); |
// Sets tag array and prepares table for initial update |
self.tagArray = [tags isEqualToString:@""] ? [[NSArray alloc] init] : [[tags lowercaseString] componentsSeparatedByString:@" "]; |
self.postCursor = nil; |
self.isLoadingBatch = NO; |
self.haveOldestPost = NO; |
[self loadBatch]; |
} |
// Called when users pulls to refresh |
- (void) loadNewPosts { |
[self loadNewPostsWithAAPLPost:nil]; |
} |
// This adds new items to the beginning of the table |
- (void) loadNewPostsWithAAPLPost:(AAPLPost *)post |
{ |
// If we don't have any posts on our table yet, fetch the first batch instead (we make assumptions in this method that we have other posts to compare to) |
if(self.postCells.count == 0 || self.lastPostSeenOnServer == nil) { |
// We dispatch it after two seconds to give the server time to index the new post |
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ |
// If we get here, we must have no posts. That must mean that last time we tried loading a batch nothing came through so we locked the method. Let's unlock it |
self.haveOldestPost = NO; |
[self loadBatch]; |
}); |
return; |
} |
// We want to strip all posts we have that haven't been seen on the server yet from tableview (order isn't guaranteed) |
NSUInteger loc = [self.postCells indexOfObject:self.lastPostSeenOnServer]; |
NSMutableArray *newPosts = [[self.postCells subarrayWithRange:NSMakeRange(0, loc)] mutableCopy]; |
[self.postCells removeObjectsInArray:newPosts]; |
// If we had a post passed in and it matches our tags, we should put that in the array too |
if(post) { |
for (NSString *tag in self.tagArray) { |
if(![post.postRecord[AAPLPostTagsKey] containsObject:tag]) |
post = nil; |
} |
} |
if(post) [newPosts addObject:post]; |
// Creates predicate based on tag string and most recent post from server |
NSMutableArray *subPredicates = [@[[NSPredicate predicateWithFormat:@"creationDate > %@", self.lastPostSeenOnServer.postRecord.creationDate]] mutableCopy]; |
for(NSString *tag in self.tagArray) { |
[subPredicates addObject:[NSPredicate predicateWithFormat:@"Tags CONTAINS %@", tag]]; |
} |
NSPredicate *finalPredicate = [NSCompoundPredicate andPredicateWithSubpredicates:subPredicates]; // ANDs all subpredicates to make a final predicate |
CKQuery *postQuery = [[CKQuery alloc] initWithRecordType:AAPLPostRecordType predicate:finalPredicate]; |
postQuery.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:YES]]; |
CKQueryOperation *queryOp = [[CKQueryOperation alloc] initWithQuery:postQuery]; |
queryOp.desiredKeys = @[AAPLPostImageRefKey,AAPLPostFontKey,AAPLPostTextKey]; |
// The last record we see will be the most recent we see on the server, we'll set the property to this in the completion block |
__block AAPLPost *lastRecordInOperation = nil; |
queryOp.recordFetchedBlock = ^(CKRecord *record) { |
// If the record we just fetched doesn't match recordIDs to any item in our newPosts array, let's make an AAPLPost and add it |
NSUInteger matchingRecord = [newPosts indexOfObjectPassingTest:^BOOL(AAPLPost *obj, NSUInteger idx, BOOL *stop) { |
if([obj.postRecord.recordID isEqual:record.recordID]) return YES; |
else return NO; |
}]; |
if(matchingRecord == NSNotFound) |
{ |
AAPLPost *fetchedPost = [[AAPLPost alloc] initWithRecord:record]; |
[newPosts addObject:fetchedPost]; |
[fetchedPost loadImageWithKeys:@[AAPLImageFullsizeKey] completion:^void(){ |
dispatch_async(dispatch_get_main_queue(), ^{ |
self.reloadBlock(); |
}); |
}]; |
lastRecordInOperation = fetchedPost; |
} |
// If we already have this record we don't have to fetch. We'll still update lastRecordInOperation because we did see it on the server |
else |
{ |
lastRecordInOperation = newPosts[matchingRecord]; |
} |
}; |
queryOp.queryCompletionBlock = ^(CKQueryCursor *cursor, NSError *operationError){ |
AAPLPostManagerErrorResponse error = [self handleError:operationError]; |
if(error == AAPLPostManagerErrorSuccess) |
{ |
// lastRecordCreationDate is the most recent record we've seen on server, let's set our property to that for next time we get a push |
if(lastRecordInOperation) { |
self.lastPostSeenOnServer = lastRecordInOperation; |
} |
// This sorts the newPosts array in ascending order |
[newPosts sortUsingComparator:^NSComparisonResult(AAPLPost *post1, AAPLPost *post2) { |
return [post1.postRecord.creationDate compare:post2.postRecord.creationDate]; |
}]; |
// Takes our newPosts array and inserts the items into the table array one at a time |
for(AAPLPost *post in newPosts) |
{ |
dispatch_async(self.updateCellArrayQueue, ^{ |
[self.postCells insertObject:post atIndex:0]; |
dispatch_async(dispatch_get_main_queue(), ^{ |
self.reloadBlock(); |
}); |
}); |
} |
dispatch_async(dispatch_get_main_queue(), ^{ |
[self.refreshControl endRefreshing]; |
}); |
} |
else if(error == AAPLPostManagerErrorRetry) |
{ |
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 loadNewPostsWithAAPLPost:post]; |
}); |
} |
else if(error == AAPLPostManagerErrorIgnore) |
{ |
NSLog(@"Error: %@", [operationError description]); |
dispatch_async(dispatch_get_main_queue(), ^{ |
[self.refreshControl endRefreshing]; |
}); |
} |
}; |
CKDatabase *publicDB = [[CKContainer defaultContainer] publicCloudDatabase]; |
queryOp.database = publicDB; |
[self.fetchRecordQueue addOperation:queryOp]; |
} |
- (void)loadBatch |
{ |
@synchronized (self) |
{ |
// Quickly returns if another loadNextBatch is running or we have the oldest post |
if(self.isLoadingBatch || self.haveOldestPost) return; |
else self.isLoadingBatch = YES; |
} |
CKQueryOperation *queryOp = nil; |
if(self.postCursor) |
{ |
// If we have a cursor, go ahead and just continue from where we left off |
queryOp = [[CKQueryOperation alloc] initWithCursor:self.postCursor]; |
} |
else |
{ |
// Create predicate out of tags. If self.tagArray is empty we should get every post |
NSMutableArray *subPredicates = [[NSMutableArray alloc] init]; |
for(NSString *tag in self.tagArray) |
{ |
NSPredicate *queryPred = [NSPredicate predicateWithFormat:[NSString stringWithFormat:@"Tags CONTAINS \'%@\'",tag]]; |
[subPredicates addObject:queryPred]; |
} |
// If our tagArray is empty, create a true predicate (as opposed to a predicate containing "Tags CONTAINS ''" |
NSPredicate *finalPredicate = [self.tagArray count] == 0 ? [NSPredicate predicateWithValue:YES] : [NSCompoundPredicate andPredicateWithSubpredicates:subPredicates]; |
CKQuery *postQuery = [[CKQuery alloc] initWithRecordType:AAPLPostRecordType predicate:finalPredicate]; |
postQuery.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]]; |
queryOp = [[CKQueryOperation alloc] initWithQuery:postQuery]; |
} |
// This query should only fetch so many records and only retrieve the information we need |
queryOp.resultsLimit = updateBy; |
queryOp.desiredKeys = @[AAPLPostImageRefKey,AAPLPostFontKey,AAPLPostTextKey]; |
NSMutableArray *newPosts = [[NSMutableArray alloc] init]; |
queryOp.recordFetchedBlock = ^(CKRecord *record) { |
// When we get a record, use it to create an AAPLPost |
AAPLPost *fetchedPost = [[AAPLPost alloc] initWithRecord:record]; |
[fetchedPost loadImageWithKeys:@[AAPLImageFullsizeKey] completion:^void(){ |
// Once image is loaded, tell the tableview to reload |
dispatch_async(dispatch_get_main_queue(), ^{ |
self.reloadBlock(); |
}); |
}]; |
[newPosts addObject:fetchedPost]; |
}; |
queryOp.queryCompletionBlock = ^(CKQueryCursor *cursor, NSError *operationError){ |
AAPLPostManagerErrorResponse error = [self handleError:operationError]; |
if(error == AAPLPostManagerErrorSuccess) |
{ |
self.postCursor = cursor; |
self.isLoadingBatch = NO; |
if(cursor == nil) self.haveOldestPost = YES; |
dispatch_sync(self.updateCellArrayQueue, ^{ |
[self.postCells addObjectsFromArray:newPosts]; |
}); |
if(!self.lastPostSeenOnServer && [self.postCells count]) |
{ |
self.lastPostSeenOnServer = self.postCells[0]; |
[self.refreshControl endRefreshing]; |
} |
dispatch_async(dispatch_get_main_queue(), ^{ |
self.reloadBlock(); |
}); |
} |
else if(error == AAPLPostManagerErrorRetry) |
{ |
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.integerValue * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ |
self.isLoadingBatch = NO; |
[self loadBatch]; |
}); |
} |
else if(error == AAPLPostManagerErrorIgnore) |
{ |
self.isLoadingBatch = NO; |
[self.refreshControl endRefreshing]; |
NSLog(@"Error: %@", [operationError description]); |
} |
}; |
CKDatabase *publicDB = [[CKContainer defaultContainer] publicCloudDatabase]; |
[queryOp setDatabase:publicDB]; |
[self.fetchRecordQueue addOperation:queryOp]; |
} |
- (AAPLPostManagerErrorResponse) handleError:(NSError *)error |
{ |
if (error == nil) { |
return AAPLPostManagerErrorSuccess; |
} |
switch ([error code]) |
{ |
case CKErrorNetworkUnavailable: |
case CKErrorNetworkFailure: |
// A reachability check might be appropriate here so we don't just keep retrying if the user has no service |
case CKErrorServiceUnavailable: |
case CKErrorRequestRateLimited: |
return AAPLPostManagerErrorRetry; |
break; |
case CKErrorUnknownItem: |
NSLog(@"If a post has never been made, CKErrorUnknownItem will be returned in AAPLPostManager because it has never seen the Post record type"); |
return AAPLPostManagerErrorIgnore; |
break; |
case CKErrorInvalidArguments: |
NSLog(@"If invalid arguments is returned in AAPLPostManager with a message about not being marked indexable or sortable, go into CloudKit dashboard and set the Post record type as sortable on date created (under metadata index)"); |
return AAPLPostManagerErrorIgnore; |
break; |
case CKErrorIncompatibleVersion: |
case CKErrorBadContainer: |
case CKErrorMissingEntitlement: |
case CKErrorPermissionFailure: |
case CKErrorBadDatabase: |
// This app uses the publicDB with default world readable permissions |
case CKErrorAssetFileNotFound: |
case CKErrorPartialFailure: |
// These shouldn't occur during a query operation |
case CKErrorQuotaExceeded: |
// We should not retry if it'll exceed our quota |
case CKErrorOperationCancelled: |
// Nothing to do here, we intentionally cancelled |
case CKErrorNotAuthenticated: |
case CKErrorResultsTruncated: |
case CKErrorServerRecordChanged: |
case CKErrorAssetFileModified: |
case CKErrorChangeTokenExpired: |
case CKErrorBatchRequestFailed: |
case CKErrorZoneBusy: |
case CKErrorZoneNotFound: |
case CKErrorLimitExceeded: |
case CKErrorUserDeletedZone: |
// All of these errors are irrelevant for this query operation |
case CKErrorInternalError: |
case CKErrorServerRejectedRequest: |
case CKErrorConstraintViolation: |
//Non-recoverable, should not retry |
default: |
return AAPLPostManagerErrorIgnore; |
break; |
} |
} |
@end |
Copyright © 2014 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2014-09-17