blob: 004dc4f0367709daf21b502eaa48cc02ed234a35 [file] [log] [blame]
/* Copyright (c) 2011 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// GTMHTTPFetcher relies on NSURLConnection, which is unavailable for apps built with minimum
// targets of iOS 9 and OS X 10.11.
//
// This fetcher class is deprecated under the OS X 10.12/iOS 10 SDKs unless targeting
// OS X 10.8 or iOS 6.
#if (!TARGET_OS_IPHONE && defined(MAC_OS_X_VERSION_10_12) \
&& MAC_OS_X_VERSION_MAX_ALLOWED >= MAC_OS_X_VERSION_10_12 \
&& MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_9)
#error GTMHTTPFetcher is deprecated; use GTMSessionFetcher instead
#endif
#if (TARGET_OS_IPHONE && defined(__IPHONE_10_0) \
&& __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0 \
&& __IPHONE_OS_VERSION_MIN_REQUIRED >= __IPHONE_7_0)
#error GTMHTTPFetcher is deprecated; use GTMSessionFetcher instead
#endif
#import "GTMHTTPFetcher.h"
#if GTM_BACKGROUND_FETCHING
#import <UIKit/UIKit.h>
#endif
#import <sys/utsname.h>
static id <GTMCookieStorageProtocol> gGTMFetcherStaticCookieStorage = nil;
static Class gGTMFetcherConnectionClass = nil;
NSString *const kGTMHTTPFetcherStartedNotification = @"kGTMHTTPFetcherStartedNotification";
NSString *const kGTMHTTPFetcherStoppedNotification = @"kGTMHTTPFetcherStoppedNotification";
NSString *const kGTMHTTPFetcherRetryDelayStartedNotification = @"kGTMHTTPFetcherRetryDelayStartedNotification";
NSString *const kGTMHTTPFetcherRetryDelayStoppedNotification = @"kGTMHTTPFetcherRetryDelayStoppedNotification";
NSString *const kGTMHTTPFetcherErrorDomain = @"com.google.GTMHTTPFetcher";
NSString *const kGTMHTTPFetcherStatusDomain = @"com.google.HTTPStatus";
NSString *const kGTMHTTPFetcherErrorChallengeKey = @"challenge";
NSString *const kGTMHTTPFetcherStatusDataKey = @"data"; // data returned with a kGTMHTTPFetcherStatusDomain error
NSString *const kGTMHTTPFetcherCompletionInvokedNotification = @"kGTMHTTPFetcherCompletionInvokedNotification";
NSString *const kGTMHTTPFetcherCompletionDataKey = @"data";
NSString *const kGTMHTTPFetcherCompletionErrorKey = @"error";
// The default max retry interview is 10 minutes for uploads (POST/PUT/PATCH),
// 1 minute for downloads.
static const NSTimeInterval kUnsetMaxRetryInterval = -1;
static const NSTimeInterval kDefaultMaxDownloadRetryInterval = 60.0;
static const NSTimeInterval kDefaultMaxUploadRetryInterval = 60.0 * 10.;
// delegateQueue callback parameters
static NSString *const kCallbackTarget = @"target";
static NSString *const kCallbackSelector = @"sel";
static NSString *const kCallbackBlock = @"block";
static NSString *const kCallbackData = @"data";
static NSString *const kCallbackError = @"error";
//
// GTMHTTPFetcher
//
@interface GTMHTTPFetcher ()
@property (copy) NSString *temporaryDownloadPath;
@property (retain) id <GTMCookieStorageProtocol> cookieStorage;
@property (readwrite, retain) NSData *downloadedData;
@property (copy) void (^completionBlock)(NSData *, NSError *);
- (BOOL)beginFetchMayDelay:(BOOL)mayDelay
mayAuthorize:(BOOL)mayAuthorize;
- (void)failToBeginFetchWithError:(NSError *)error;
- (void)failToBeginFetchDeferWithError:(NSError *)error;
#if GTM_BACKGROUND_FETCHING
- (void)endBackgroundTask;
- (void)backgroundFetchExpired;
#endif
- (BOOL)authorizeRequest;
- (void)authorizer:(id <GTMFetcherAuthorizationProtocol>)auth
request:(NSMutableURLRequest *)request
finishedWithError:(NSError *)error;
- (NSString *)createTempDownloadFilePathForPath:(NSString *)targetPath;
- (void)stopFetchReleasingCallbacks:(BOOL)shouldReleaseCallbacks;
- (BOOL)shouldReleaseCallbacksUponCompletion;
- (void)addCookiesToRequest:(NSMutableURLRequest *)request;
- (void)handleCookiesForResponse:(NSURLResponse *)response;
- (void)invokeFetchCallbacksWithData:(NSData *)data
error:(NSError *)error;
- (void)invokeFetchCallbacksWithTarget:(id)target
selector:(SEL)sel
block:(id)block
data:(NSData *)data
error:(NSError *)error;
- (void)invokeFetchCallback:(SEL)sel
target:(id)target
data:(NSData *)data
error:(NSError *)error;
- (void)invokeFetchCallbacksOnDelegateQueueWithData:(NSData *)data
error:(NSError *)error;
- (void)invokeOnQueueWithDictionary:(NSDictionary *)dict;
- (void)releaseCallbacks;
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error;
- (BOOL)shouldRetryNowForStatus:(NSInteger)status error:(NSError *)error;
- (void)destroyRetryTimer;
- (void)beginRetryTimer;
- (void)primeRetryTimerWithNewTimeInterval:(NSTimeInterval)secs;
- (void)sendStopNotificationIfNeeded;
- (void)retryFetch;
- (void)retryTimerFired:(NSTimer *)timer;
@end
@interface GTMHTTPFetcher (GTMHTTPFetcherLoggingInternal)
- (void)setupStreamLogging;
- (void)logFetchWithError:(NSError *)error;
- (void)logNowWithError:(NSError *)error;
@end
@implementation GTMHTTPFetcher
+ (GTMHTTPFetcher *)fetcherWithRequest:(NSURLRequest *)request {
return [[[[self class] alloc] initWithRequest:request] autorelease];
}
+ (GTMHTTPFetcher *)fetcherWithURL:(NSURL *)requestURL {
return [self fetcherWithRequest:[NSURLRequest requestWithURL:requestURL]];
}
+ (GTMHTTPFetcher *)fetcherWithURLString:(NSString *)requestURLString {
return [self fetcherWithURL:[NSURL URLWithString:requestURLString]];
}
+ (void)initialize {
// initialize is guaranteed by the runtime to be called in a
// thread-safe manner
if (!gGTMFetcherStaticCookieStorage) {
Class cookieStorageClass = NSClassFromString(@"GTMCookieStorage");
if (cookieStorageClass) {
gGTMFetcherStaticCookieStorage = [[cookieStorageClass alloc] init];
}
#if DEBUG
#if (!TARGET_OS_IPHONE && MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_9) \
|| (TARGET_OS_IPHONE && __IPHONE_OS_VERSION_MIN_REQUIRED >= __IPHONE_7_0)
NSLog(@"GTMHTTPFetcher is deprecated and should be updated to GTMSessionFetcher"
@" (inside bundle %@)",
[NSBundle bundleForClass:self].bundlePath.lastPathComponent);
#endif
#endif
}
}
- (id)init {
return [self initWithRequest:nil];
}
- (id)initWithRequest:(NSURLRequest *)request {
self = [super init];
if (self) {
request_ = [request mutableCopy];
if (gGTMFetcherStaticCookieStorage != nil) {
// The user has compiled with the cookie storage class available;
// default to static cookie storage, so our cookies are independent
// of the cookies of other apps.
[self setCookieStorageMethod:kGTMHTTPFetcherCookieStorageMethodStatic];
} else {
// Default to system default cookie storage
[self setCookieStorageMethod:kGTMHTTPFetcherCookieStorageMethodSystemDefault];
}
#if !STRIP_GTM_FETCH_LOGGING
// Encourage developers to set the comment property or use
// setCommentWithFormat: by providing a default string.
comment_ = @"(No fetcher comment set)";
#endif
}
return self;
}
- (id)copyWithZone:(NSZone *)zone {
// disallow use of fetchers in a copy property
[self doesNotRecognizeSelector:_cmd];
return nil;
}
- (NSString *)description {
return [NSString stringWithFormat:@"%@ %p (%@)",
[self class], self, [self.mutableRequest URL]];
}
#if !GTM_IPHONE
- (void)finalize {
[self stopFetchReleasingCallbacks:YES]; // releases connection_, destroys timers
[super finalize];
}
#endif
- (void)dealloc {
#if DEBUG
NSAssert(!isStopNotificationNeeded_,
@"unbalanced fetcher notification for %@", [request_ URL]);
#endif
// Note: if a connection or a retry timer was pending, then this instance
// would be retained by those so it wouldn't be getting dealloc'd,
// hence we don't need to stopFetch here
[request_ release];
[connection_ release];
[downloadedData_ release];
[downloadPath_ release];
[temporaryDownloadPath_ release];
[downloadFileHandle_ release];
[credential_ release];
[proxyCredential_ release];
[bodyData_ release];
[postStream_ release];
[loggedStreamData_ release];
[response_ release];
[completionBlock_ release];
[receivedDataBlock_ release];
[sentDataBlock_ release];
[retryBlock_ release];
[userData_ release];
[properties_ release];
[delegateQueue_ release];
[runLoopModes_ release];
[fetchHistory_ release];
[cookieStorage_ release];
[authorizer_ release];
[service_ release];
[serviceHost_ release];
[thread_ release];
[retryTimer_ release];
[initialRequestDate_ release];
[comment_ release];
[log_ release];
[allowedInsecureSchemes_ release];
// releaseCallbacks nilifies delegate_ making this release a no-op if releaseCallbacks was called
[delegate_ release];
#if !STRIP_GTM_FETCH_LOGGING
[redirectedFromURL_ release];
[logRequestBody_ release];
[logResponseBody_ release];
#endif
[super dealloc];
}
#pragma mark -
// Begin fetching the URL (or begin a retry fetch). The delegate is retained
// for the duration of the fetch connection.
- (BOOL)beginFetchWithDelegate:(id)delegate
didFinishSelector:(SEL)finishedSelector {
GTMAssertSelectorNilOrImplementedWithArgs(delegate, finishedSelector, @encode(GTMHTTPFetcher *), @encode(NSData *), @encode(NSError *), 0);
GTMAssertSelectorNilOrImplementedWithArgs(delegate, receivedDataSel_, @encode(GTMHTTPFetcher *), @encode(NSData *), 0);
GTMAssertSelectorNilOrImplementedWithArgs(delegate, retrySel_, @encode(GTMHTTPFetcher *), @encode(BOOL), @encode(NSError *), 0);
// We'll retain the delegate only during the outstanding connection (similar
// to what Cocoa does with performSelectorOnMainThread:) and during
// authorization or delays, since the app would crash
// if the delegate was released before the fetch calls back
[self setDelegate:delegate];
finishedSel_ = finishedSelector;
return [self beginFetchMayDelay:YES
mayAuthorize:YES];
}
- (BOOL)beginFetchMayDelay:(BOOL)mayDelay
mayAuthorize:(BOOL)mayAuthorize {
// This is the internal entry point for re-starting fetches
NSError *error = nil;
if (connection_ != nil) {
NSAssert1(connection_ != nil, @"fetch object %@ being reused; this should never happen", self);
goto CannotBeginFetch;
}
NSURL *requestURL = [request_ URL];
if (request_ == nil || requestURL == nil) {
NSAssert(request_ != nil, @"beginFetchWithDelegate requires a request with a URL");
goto CannotBeginFetch;
}
#if !GTM_ALLOW_INSECURE_REQUESTS
if (requestURL != nil) {
// Allow https only for requests, unless overridden by the client.
//
// Non-https requests may too easily be snooped, so we disallow them by default.
//
// file: and data: schemes are usually safe if they are hardcoded in the client or provided
// by a trusted source, but since it's fairly rare to need them, it's safest to make clients
// explicitly whitelist them.
NSString *requestScheme = [requestURL scheme];
BOOL isSecure = ([requestScheme caseInsensitiveCompare:@"https"] == NSOrderedSame);
if (!isSecure) {
BOOL allowRequest = NO;
NSString *host = [requestURL host];
BOOL isLocalhost = ([host caseInsensitiveCompare:@"localhost"] == NSOrderedSame
|| [host isEqual:@"::1"]
|| [host isEqual:@"127.0.0.1"]);
if (isLocalhost) {
if (allowLocalhostRequest_) {
allowRequest = YES;
} else {
// To fetch from localhost, the fetcher must specifically have the allowLocalhostRequest
// property set.
#if DEBUG
NSAssert(NO, @"Fetch request for localhost but fetcher allowLocalhostRequest"
@" is not set: %@", requestURL);
#else
NSLog(@"Localhost fetch disallowed for %@", requestURL);
#endif
}
} else {
// Not localhost; check schemes.
for (NSString *allowedScheme in allowedInsecureSchemes_) {
if ([requestScheme caseInsensitiveCompare:allowedScheme] == NSOrderedSame) {
allowRequest = YES;
break;
}
}
if (!allowRequest) {
// To make a request other than https:, the client must specify an array for the
// allowedInsecureSchemes property.
#if DEBUG
NSAssert(NO, @"Insecure fetch request has a scheme (%@)"
@" not found in fetcher allowedInsecureSchemes (%@): %@",
requestScheme, allowedInsecureSchemes_, requestURL);
#else
NSLog(@"Fetch disallowed for %@", requestURL);
#endif
}
}
if (!allowRequest) {
error = [NSError errorWithDomain:kGTMHTTPFetcherErrorDomain
code:kGTMHTTPFetcherErrorInsecureRequest
userInfo:nil];
goto CannotBeginFetch;
}
} // !isSecure
} // requestURL != nil
#endif // GTM_ALLOW_INSECURE_REQUESTS
self.downloadedData = nil;
downloadedLength_ = 0;
if (servicePriority_ == NSIntegerMin) {
mayDelay = NO;
}
if (mayDelay && service_) {
BOOL shouldFetchNow = [service_ fetcherShouldBeginFetching:self];
if (!shouldFetchNow) {
// the fetch is deferred, but will happen later
return YES;
}
}
NSString *effectiveHTTPMethod = [request_ valueForHTTPHeaderField:@"X-HTTP-Method-Override"];
if (effectiveHTTPMethod == nil) {
effectiveHTTPMethod = [request_ HTTPMethod];
}
BOOL isEffectiveHTTPGet = (effectiveHTTPMethod == nil
|| [effectiveHTTPMethod isEqual:@"GET"]);
if (bodyData_ || postStream_) {
if (isEffectiveHTTPGet) {
[request_ setHTTPMethod:@"POST"];
isEffectiveHTTPGet = NO;
}
if (bodyData_) {
[request_ setHTTPBody:bodyData_];
} else {
if ([self respondsToSelector:@selector(setupStreamLogging)]) {
[self performSelector:@selector(setupStreamLogging)];
}
[request_ setHTTPBodyStream:postStream_];
}
}
// We authorize after setting up the http method and body in the request
// because OAuth 1 may need to sign the request body
if (mayAuthorize && authorizer_) {
BOOL isAuthorized = [authorizer_ isAuthorizedRequest:request_];
if (!isAuthorized) {
// authorization needed
return [self authorizeRequest];
}
}
[fetchHistory_ updateRequest:request_ isHTTPGet:isEffectiveHTTPGet];
// set the default upload or download retry interval, if necessary
if (isRetryEnabled_
&& maxRetryInterval_ <= kUnsetMaxRetryInterval) {
if (isEffectiveHTTPGet || [effectiveHTTPMethod isEqual:@"HEAD"]) {
[self setMaxRetryInterval:kDefaultMaxDownloadRetryInterval];
} else {
[self setMaxRetryInterval:kDefaultMaxUploadRetryInterval];
}
}
[self addCookiesToRequest:request_];
if (downloadPath_ != nil) {
// downloading to a path, so create a temporary file and a file handle for
// downloading
NSString *tempPath = [self createTempDownloadFilePathForPath:downloadPath_];
BOOL didCreate = [[NSData data] writeToFile:tempPath
options:0
error:&error];
if (!didCreate) goto CannotBeginFetch;
[self setTemporaryDownloadPath:tempPath];
NSFileHandle *fh = [NSFileHandle fileHandleForWritingAtPath:tempPath];
if (fh == nil) goto CannotBeginFetch;
[self setDownloadFileHandle:fh];
}
// finally, start the connection
Class connectionClass = [[self class] connectionClass];
NSOperationQueue *delegateQueue = delegateQueue_;
if (delegateQueue &&
![connectionClass instancesRespondToSelector:@selector(setDelegateQueue:)]) {
// NSURLConnection has no setDelegateQueue: on iOS 4 and Mac OS X 10.5.
delegateQueue = nil;
self.delegateQueue = nil;
} else if (delegateQueue == nil && runLoopModes_ == nil && ![NSThread isMainThread]) {
// Neither a delegate queue nor runLoopModes were supplied, and we're not on the
// main thread, so assume the user really wants callbacks and provide a queue.
//
// We don't have a way to verify that this thread has a run loop spinning, but
// it's fairly rare that a background thread does have one. A client that
// does want to rely on spinning a run loop should specify run loop modes.
delegateQueue = [NSOperationQueue mainQueue];
self.delegateQueue = delegateQueue;
}
#if DEBUG && TARGET_OS_IPHONE
BOOL isPreIOS6 = (NSFoundationVersionNumber <= 890.1);
if (isPreIOS6 && delegateQueue) {
NSLog(@"GTMHTTPFetcher delegateQueue not safe in iOS 5");
}
#endif
if (downloadFileHandle_ != nil) {
// Downloading to a file, so downloadedData_ remains nil.
} else {
self.downloadedData = [NSMutableData data];
}
hasConnectionEnded_ = NO;
if ([runLoopModes_ count] == 0 && delegateQueue == nil) {
// No custom callback modes or queue were specified, so start the connection
// on the current run loop in the current mode
connection_ = [[connectionClass connectionWithRequest:request_
delegate:self] retain];
} else {
// Specify callbacks be on an operation queue or on the current run loop
// in the specified modes
connection_ = [[connectionClass alloc] initWithRequest:request_
delegate:self
startImmediately:NO];
if (delegateQueue) {
[connection_ performSelector:@selector(setDelegateQueue:)
withObject:delegateQueue];
} else if (runLoopModes_) {
NSRunLoop *rl = [NSRunLoop currentRunLoop];
for (NSString *mode in runLoopModes_) {
[connection_ scheduleInRunLoop:rl forMode:mode];
}
}
[connection_ start];
}
if (!connection_) {
NSAssert(connection_ != nil, @"beginFetchWithDelegate could not create a connection");
self.downloadedData = nil;
goto CannotBeginFetch;
}
#if GTM_BACKGROUND_FETCHING
backgroundTaskIdentifer_ = 0; // UIBackgroundTaskInvalid is 0 on iOS 4
if (shouldFetchInBackground_) {
// For iOS 3 compatibility, ensure that UIApp supports backgrounding
UIApplication *app = [UIApplication sharedApplication];
if ([app respondsToSelector:@selector(beginBackgroundTaskWithExpirationHandler:)]) {
// Tell UIApplication that we want to continue even when the app is in the
// background.
NSThread *thread = delegateQueue_ ? nil : [NSThread currentThread];
backgroundTaskIdentifer_ = [app beginBackgroundTaskWithExpirationHandler:^{
// Background task expiration callback - this block is always invoked by
// UIApplication on the main thread.
if (thread) {
// Run the user's callbacks on the thread used to start the
// fetch.
[self performSelector:@selector(backgroundFetchExpired)
onThread:thread
withObject:nil
waitUntilDone:YES];
} else {
// backgroundFetchExpired invokes callbacks on the provided delegate
// queue.
[self backgroundFetchExpired];
}
}];
}
}
#endif
if (!initialRequestDate_) {
initialRequestDate_ = [[NSDate alloc] init];
}
#if DEBUG
// For testing only, look for a property indicating the fetch should immediately fail.
if ([self propertyForKey:@"_CannotBeginFetch"] != nil) {
goto CannotBeginFetch;
}
#endif
// Once connection_ is non-nil we can send the start notification
isStopNotificationNeeded_ = YES;
NSNotificationCenter *defaultNC = [NSNotificationCenter defaultCenter];
[defaultNC postNotificationName:kGTMHTTPFetcherStartedNotification
object:self];
return YES;
CannotBeginFetch:
[self failToBeginFetchDeferWithError:error];
return NO;
}
- (void)failToBeginFetchDeferWithError:(NSError *)error {
if (delegateQueue_) {
// Deferring will happen by the callback being invoked on the specified
// queue.
[self failToBeginFetchWithError:error];
} else {
// No delegate queue has been specified, so put the callback
// on an appropriate run loop.
NSArray *modes = (runLoopModes_ ? runLoopModes_ :
[NSArray arrayWithObject:NSRunLoopCommonModes]);
[self performSelector:@selector(failToBeginFetchWithError:)
onThread:[NSThread currentThread]
withObject:error
waitUntilDone:NO
modes:modes];
}
}
- (void)failToBeginFetchWithError:(NSError *)error {
if (error == nil) {
error = [NSError errorWithDomain:kGTMHTTPFetcherErrorDomain
code:kGTMHTTPFetcherErrorDownloadFailed
userInfo:nil];
}
[[self retain] autorelease]; // In case the callback releases us
[self invokeFetchCallbacksOnDelegateQueueWithData:nil
error:error];
[self releaseCallbacks];
[service_ fetcherDidStop:self];
self.authorizer = nil;
if (temporaryDownloadPath_) {
[[NSFileManager defaultManager] removeItemAtPath:temporaryDownloadPath_
error:NULL];
self.temporaryDownloadPath = nil;
}
}
#if GTM_BACKGROUND_FETCHING
- (void)backgroundFetchExpired {
// On background expiration, we stop the fetch and invoke the callbacks
NSError *error = [NSError errorWithDomain:kGTMHTTPFetcherErrorDomain
code:kGTMHTTPFetcherErrorBackgroundExpiration
userInfo:nil];
[self invokeFetchCallbacksOnDelegateQueueWithData:nil
error:error];
@synchronized(self) {
// Stopping the fetch here will indirectly call endBackgroundTask
[self stopFetchReleasingCallbacks:NO];
[self releaseCallbacks];
self.authorizer = nil;
}
}
- (void)endBackgroundTask {
@synchronized(self) {
// Whenever the connection stops or background execution expires,
// we need to tell UIApplication we're done
if (backgroundTaskIdentifer_) {
// If backgroundTaskIdentifer_ is non-zero, we know we're on iOS 4
UIApplication *app = [UIApplication sharedApplication];
[app endBackgroundTask:backgroundTaskIdentifer_];
backgroundTaskIdentifer_ = 0;
}
}
}
#endif // GTM_BACKGROUND_FETCHING
- (BOOL)authorizeRequest {
id authorizer = self.authorizer;
SEL asyncAuthSel = @selector(authorizeRequest:delegate:didFinishSelector:);
if ([authorizer respondsToSelector:asyncAuthSel]) {
SEL callbackSel = @selector(authorizer:request:finishedWithError:);
[authorizer authorizeRequest:request_
delegate:self
didFinishSelector:callbackSel];
return YES;
} else {
NSAssert(authorizer == nil, @"invalid authorizer for fetch");
// No authorizing possible, and authorizing happens only after any delay;
// just begin fetching
return [self beginFetchMayDelay:NO
mayAuthorize:NO];
}
}
- (void)authorizer:(id <GTMFetcherAuthorizationProtocol>)auth
request:(NSMutableURLRequest *)request
finishedWithError:(NSError *)error {
if (error != nil) {
// We can't fetch without authorization
[self failToBeginFetchDeferWithError:error];
} else {
[self beginFetchMayDelay:NO
mayAuthorize:NO];
}
}
- (BOOL)beginFetchWithCompletionHandler:(void (^)(NSData *data, NSError *error))handler {
self.completionBlock = handler;
// The user may have called setDelegate: earlier if they want to use other
// delegate-style callbacks during the fetch; otherwise, the delegate is nil,
// which is fine.
return [self beginFetchWithDelegate:[self delegate]
didFinishSelector:nil];
}
- (NSString *)createTempDownloadFilePathForPath:(NSString *)targetPath {
NSString *tempDir = nil;
#if (!TARGET_OS_IPHONE && (MAC_OS_X_VERSION_MAX_ALLOWED >= 1060))
// Find an appropriate directory for the download, ideally on the same disk
// as the final target location so the temporary file won't have to be moved
// to a different disk.
//
// Available in SDKs for 10.6 and iOS 4
//
// Oct 2011: We previously also used URLForDirectory for
// (TARGET_OS_IPHONE && (__IPHONE_OS_VERSION_MAX_ALLOWED >= 40000))
// but that is returning a non-temporary directory for iOS, unfortunately
SEL sel = @selector(URLForDirectory:inDomain:appropriateForURL:create:error:);
if ([NSFileManager instancesRespondToSelector:sel]) {
NSError *error = nil;
NSURL *targetURL = [NSURL fileURLWithPath:targetPath];
NSFileManager *fileMgr = [NSFileManager defaultManager];
NSURL *tempDirURL = [fileMgr URLForDirectory:NSItemReplacementDirectory
inDomain:NSUserDomainMask
appropriateForURL:targetURL
create:YES
error:&error];
tempDir = [tempDirURL path];
}
#endif
if (tempDir == nil) {
tempDir = NSTemporaryDirectory();
}
static unsigned int counter = 0;
NSString *name = [NSString stringWithFormat:@"gtmhttpfetcher_%u_%u",
++counter, (unsigned int) arc4random()];
NSString *result = [tempDir stringByAppendingPathComponent:name];
return result;
}
- (void)addCookiesToRequest:(NSMutableURLRequest *)request {
// Get cookies for this URL from our storage array, if
// we have a storage array
if (cookieStorageMethod_ != kGTMHTTPFetcherCookieStorageMethodSystemDefault
&& cookieStorageMethod_ != kGTMHTTPFetcherCookieStorageMethodNone) {
NSArray *cookies = [cookieStorage_ cookiesForURL:[request URL]];
if ([cookies count] > 0) {
NSDictionary *headerFields = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
NSString *cookieHeader = [headerFields objectForKey:@"Cookie"]; // key used in header dictionary
if (cookieHeader) {
[request addValue:cookieHeader forHTTPHeaderField:@"Cookie"]; // header name
}
}
}
}
// Returns YES if this is in the process of fetching a URL, or waiting to
// retry, or waiting for authorization, or waiting to be issued by the
// service object
- (BOOL)isFetching {
if (connection_ != nil || retryTimer_ != nil) return YES;
BOOL isAuthorizing = [authorizer_ isAuthorizingRequest:request_];
if (isAuthorizing) return YES;
BOOL isDelayed = [service_ isDelayingFetcher:self];
return isDelayed;
}
// Returns the status code set in connection:didReceiveResponse:
- (NSInteger)statusCode {
NSInteger statusCode;
if (response_ != nil
&& [response_ respondsToSelector:@selector(statusCode)]) {
statusCode = [(NSHTTPURLResponse *)response_ statusCode];
} else {
// Default to zero, in hopes of hinting "Unknown" (we can't be
// sure that things are OK enough to use 200).
statusCode = 0;
}
return statusCode;
}
- (NSDictionary *)responseHeaders {
if (response_ != nil
&& [response_ respondsToSelector:@selector(allHeaderFields)]) {
NSDictionary *headers = [(NSHTTPURLResponse *)response_ allHeaderFields];
return headers;
}
return nil;
}
- (void)releaseCallbacks {
[delegate_ autorelease];
delegate_ = nil;
[delegateQueue_ autorelease];
delegateQueue_ = nil;
self.completionBlock = nil;
self.sentDataBlock = nil;
self.receivedDataBlock = nil;
self.retryBlock = nil;
}
// Cancel the fetch of the URL that's currently in progress.
- (void)stopFetchReleasingCallbacks:(BOOL)shouldReleaseCallbacks {
id <GTMHTTPFetcherServiceProtocol> service;
// if the connection or the retry timer is all that's retaining the fetcher,
// we want to be sure this instance survives stopping at least long enough for
// the stack to unwind
[[self retain] autorelease];
[self destroyRetryTimer];
@synchronized(self) {
service = [[service_ retain] autorelease];
if (connection_) {
// in case cancelling the connection calls this recursively, we want
// to ensure that we'll only release the connection and delegate once,
// so first set connection_ to nil
NSURLConnection* oldConnection = connection_;
connection_ = nil;
if (!hasConnectionEnded_) {
[oldConnection cancel];
}
// this may be called in a callback from the connection, so use autorelease
[oldConnection autorelease];
}
} // @synchronized(self)
// send the stopped notification
[self sendStopNotificationIfNeeded];
@synchronized(self) {
[authorizer_ stopAuthorizationForRequest:request_];
if (shouldReleaseCallbacks) {
[self releaseCallbacks];
self.authorizer = nil;
}
if (temporaryDownloadPath_) {
[[NSFileManager defaultManager] removeItemAtPath:temporaryDownloadPath_
error:NULL];
self.temporaryDownloadPath = nil;
}
} // @synchronized(self)
[service fetcherDidStop:self];
#if GTM_BACKGROUND_FETCHING
[self endBackgroundTask];
#endif
}
// External stop method
- (void)stopFetching {
[self stopFetchReleasingCallbacks:YES];
}
- (void)sendStopNotificationIfNeeded {
BOOL sendNow = NO;
@synchronized(self) {
if (isStopNotificationNeeded_) {
isStopNotificationNeeded_ = NO;
sendNow = YES;
}
}
if (sendNow) {
NSNotificationCenter *defaultNC = [NSNotificationCenter defaultCenter];
[defaultNC postNotificationName:kGTMHTTPFetcherStoppedNotification
object:self];
}
}
- (void)retryFetch {
[self stopFetchReleasingCallbacks:NO];
[self beginFetchWithDelegate:delegate_
didFinishSelector:finishedSel_];
}
- (void)waitForCompletionWithTimeout:(NSTimeInterval)timeoutInSeconds {
NSDate* giveUpDate = [NSDate dateWithTimeIntervalSinceNow:timeoutInSeconds];
// Loop until the callbacks have been called and released, and until
// the connection is no longer pending, or until the timeout has expired
BOOL isMainThread = [NSThread isMainThread];
while ((!hasConnectionEnded_
|| completionBlock_ != nil
|| delegate_ != nil)
&& [giveUpDate timeIntervalSinceNow] > 0) {
// Run the current run loop 1/1000 of a second to give the networking
// code a chance to work
if (isMainThread || delegateQueue_ == nil) {
NSDate *stopDate = [NSDate dateWithTimeIntervalSinceNow:0.001];
[[NSRunLoop currentRunLoop] runUntilDate:stopDate];
} else {
[NSThread sleepForTimeInterval:0.001];
}
}
}
#pragma mark NSURLConnection Delegate Methods
//
// NSURLConnection Delegate Methods
//
// This method just says "follow all redirects", which _should_ be the default behavior,
// According to file:///Developer/ADC%20Reference%20Library/documentation/Cocoa/Conceptual/URLLoadingSystem
// but the redirects were not being followed until I added this method. May be
// a bug in the NSURLConnection code, or the documentation.
//
// In OS X 10.4.8 and earlier, the redirect request doesn't
// get the original's headers and body. This causes POSTs to fail.
// So we construct a new request, a copy of the original, with overrides from the
// redirect.
//
// Docs say that if redirectResponse is nil, just return the redirectRequest.
- (NSURLRequest *)connection:(NSURLConnection *)connection
willSendRequest:(NSURLRequest *)redirectRequest
redirectResponse:(NSURLResponse *)redirectResponse {
@synchronized(self) {
if (redirectRequest && redirectResponse) {
// save cookies from the response
[self handleCookiesForResponse:redirectResponse];
NSMutableURLRequest *newRequest = [[request_ mutableCopy] autorelease];
// copy the URL
NSURL *redirectURL = [redirectRequest URL];
NSURL *url = [newRequest URL];
// disallow scheme changes (say, from https to http)
NSString *redirectScheme = [url scheme];
NSString *newScheme = [redirectURL scheme];
NSString *newResourceSpecifier = [redirectURL resourceSpecifier];
if ([redirectScheme caseInsensitiveCompare:@"http"] == NSOrderedSame
&& newScheme != nil
&& [newScheme caseInsensitiveCompare:@"https"] == NSOrderedSame) {
// allow the change from http to https
redirectScheme = newScheme;
}
NSString *newUrlString = [NSString stringWithFormat:@"%@:%@",
redirectScheme, newResourceSpecifier];
NSURL *newURL = [NSURL URLWithString:newUrlString];
[newRequest setURL:newURL];
// any headers in the redirect override headers in the original.
NSDictionary *redirectHeaders = [redirectRequest allHTTPHeaderFields];
for (NSString *key in redirectHeaders) {
NSString *value = [redirectHeaders objectForKey:key];
[newRequest setValue:value forHTTPHeaderField:key];
}
[self addCookiesToRequest:newRequest];
redirectRequest = newRequest;
// log the response we just received
[self setResponse:redirectResponse];
[self logNowWithError:nil];
// update the request for future logging
NSMutableURLRequest *mutable = [[redirectRequest mutableCopy] autorelease];
[self setMutableRequest:mutable];
}
return redirectRequest;
} // @synchronized(self)
}
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
@synchronized(self) {
// This method is called when the server has determined that it
// has enough information to create the NSURLResponse
// it can be called multiple times, for example in the case of a
// redirect, so each time we reset the data.
[downloadedData_ setLength:0];
[downloadFileHandle_ truncateFileAtOffset:0];
downloadedLength_ = 0;
[self setResponse:response];
// Save cookies from the response
[self handleCookiesForResponse:response];
}
}
// handleCookiesForResponse: handles storage of cookies for responses passed to
// connection:willSendRequest:redirectResponse: and connection:didReceiveResponse:
- (void)handleCookiesForResponse:(NSURLResponse *)response {
if (cookieStorageMethod_ == kGTMHTTPFetcherCookieStorageMethodSystemDefault
|| cookieStorageMethod_ == kGTMHTTPFetcherCookieStorageMethodNone) {
// do nothing special for NSURLConnection's default storage mechanism
// or when we're ignoring cookies
} else if ([response respondsToSelector:@selector(allHeaderFields)]) {
// grab the cookies from the header as NSHTTPCookies and store them either
// into our static array or into the fetchHistory
NSDictionary *responseHeaderFields = [(NSHTTPURLResponse *)response allHeaderFields];
if (responseHeaderFields) {
NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:responseHeaderFields
forURL:[response URL]];
if ([cookies count] > 0) {
[cookieStorage_ setCookies:cookies];
}
}
}
}
-(void)connection:(NSURLConnection *)connection
didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge {
@synchronized(self) {
if ([challenge previousFailureCount] <= 2) {
NSURLCredential *credential = credential_;
if ([[challenge protectionSpace] isProxy] && proxyCredential_ != nil) {
credential = proxyCredential_;
}
// Here, if credential is still nil, then we *could* try to get it from
// NSURLCredentialStorage's defaultCredentialForProtectionSpace:.
// We don't, because we're assuming:
//
// - for server credentials, we only want ones supplied by the program
// calling http fetcher
// - for proxy credentials, if one were necessary and available in the
// keychain, it would've been found automatically by NSURLConnection
// and this challenge delegate method never would've been called
// anyway
if (credential) {
// try the credential
[[challenge sender] useCredential:credential
forAuthenticationChallenge:challenge];
return;
}
} // @synchronized(self)
// If we don't have credentials, or we've already failed auth 3x,
// report the error, putting the challenge as a value in the userInfo
// dictionary.
#if DEBUG
NSAssert(!isCancellingChallenge_, @"isCancellingChallenge_ unexpected");
#endif
NSDictionary *userInfo = [NSDictionary dictionaryWithObject:challenge
forKey:kGTMHTTPFetcherErrorChallengeKey];
NSError *error = [NSError errorWithDomain:kGTMHTTPFetcherErrorDomain
code:kGTMHTTPFetcherErrorAuthenticationChallengeFailed
userInfo:userInfo];
// cancelAuthenticationChallenge seems to indirectly call
// connection:didFailWithError: now, though that isn't documented
//
// We'll use an ivar to make the indirect invocation of the
// delegate method do nothing.
isCancellingChallenge_ = YES;
[[challenge sender] cancelAuthenticationChallenge:challenge];
isCancellingChallenge_ = NO;
[self connection:connection didFailWithError:error];
}
}
- (void)invokeFetchCallbacksWithData:(NSData *)data
error:(NSError *)error {
// To avoid deadlocks, this should not be called inside of @synchronized(self)
id target;
SEL sel;
void (^block)(NSData *, NSError *);
// If -stopFetching is called in another thread directly after this @synchronized stanza finishes
// on this thread, then target and block could be released before being used in this method. So
// retain each until this method is done with them.
@synchronized(self) {
target = [[delegate_ retain] autorelease];
sel = finishedSel_;
block = [[completionBlock_ retain] autorelease];
}
[self invokeFetchCallbacksWithTarget:target
selector:sel
block:block
data:data
error:error];
}
- (void)invokeFetchCallbacksWithTarget:(id)target
selector:(SEL)sel
block:(id)block
data:(NSData *)data
error:(NSError *)error {
[[self retain] autorelease]; // In case the callback releases us
[self invokeFetchCallback:sel
target:target
data:data
error:error];
if (block) {
((void (^)(NSData *, NSError *))block)(data, error);
}
// Post a notification, primarily to allow code to collect responses for
// testing.
//
// The observing code is not likely on the fetcher's callback
// queue, so this posts explicitly to the main queue.
dispatch_async(dispatch_get_main_queue(), ^{
NSMutableDictionary *userInfo = [NSMutableDictionary dictionary];
if (data) {
[userInfo setObject:data forKey:kGTMHTTPFetcherCompletionDataKey];
}
if (error) {
[userInfo setObject:error forKey:kGTMHTTPFetcherCompletionErrorKey];
}
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc postNotificationName:kGTMHTTPFetcherCompletionInvokedNotification
object:self
userInfo:userInfo];
});
}
- (void)invokeFetchCallback:(SEL)sel
target:(id)target
data:(NSData *)data
error:(NSError *)error {
// This method is available to subclasses which may provide a customized
// target pointer.
if (target && sel) {
NSMethodSignature *sig = [target methodSignatureForSelector:sel];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sig];
[invocation setSelector:sel];
[invocation setTarget:target];
[invocation setArgument:&self atIndex:2];
[invocation setArgument:&data atIndex:3];
[invocation setArgument:&error atIndex:4];
[invocation invoke];
}
}
- (void)invokeFetchCallbacksOnDelegateQueueWithData:(NSData *)data
error:(NSError *)error {
// This is called by methods that are not already on the delegateQueue
// (as NSURLConnection callbacks should already be, but other failures
// are not.)
if (!delegateQueue_) {
[self invokeFetchCallbacksWithData:data error:error];
}
// Values may be nil.
NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithCapacity:2];
[dict setValue:data forKey:kCallbackData];
[dict setValue:error forKey:kCallbackError];
// If -stopFetching is called in another thread directly after this @synchronized stanza finishes
// on this thread, then target and block could be released before being used in this method. So
// retain each until this method is done with them.
@synchronized(self) {
id target = delegate_;
NSString *sel = finishedSel_ ? NSStringFromSelector(finishedSel_) : nil;
void (^block)(NSData *, NSError *) = completionBlock_;
[dict setValue:target forKey:kCallbackTarget];
[dict setValue:sel forKey:kCallbackSelector];
[dict setValue:block forKey:kCallbackBlock];
}
NSInvocationOperation *op =
[[[NSInvocationOperation alloc] initWithTarget:self
selector:@selector(invokeOnQueueWithDictionary:)
object:dict] autorelease];
[delegateQueue_ addOperation:op];
}
- (void)invokeOnQueueWithDictionary:(NSDictionary *)dict {
id target = [dict objectForKey:kCallbackTarget];
NSString *selStr = [dict objectForKey:kCallbackSelector];
SEL sel = selStr ? NSSelectorFromString(selStr) : NULL;
id block = [dict objectForKey:kCallbackBlock];
NSData *data = [dict objectForKey:kCallbackData];
NSError *error = [dict objectForKey:kCallbackError];
[self invokeFetchCallbacksWithTarget:target
selector:sel
block:block
data:data
error:error];
}
- (void)invokeSentDataCallback:(SEL)sel
target:(id)target
didSendBodyData:(NSInteger)bytesWritten
totalBytesWritten:(NSInteger)totalBytesWritten
totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite {
if (target && sel) {
NSMethodSignature *sig = [target methodSignatureForSelector:sel];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sig];
[invocation setSelector:sel];
[invocation setTarget:target];
[invocation setArgument:&self atIndex:2];
[invocation setArgument:&bytesWritten atIndex:3];
[invocation setArgument:&totalBytesWritten atIndex:4];
[invocation setArgument:&totalBytesExpectedToWrite atIndex:5];
[invocation invoke];
}
}
- (BOOL)invokeRetryCallback:(SEL)sel
target:(id)target
willRetry:(BOOL)willRetry
error:(NSError *)error {
if (target && sel) {
NSMethodSignature *sig = [target methodSignatureForSelector:sel];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sig];
[invocation setSelector:sel];
[invocation setTarget:target];
[invocation setArgument:&self atIndex:2];
[invocation setArgument:&willRetry atIndex:3];
[invocation setArgument:&error atIndex:4];
[invocation invoke];
[invocation getReturnValue:&willRetry];
}
return willRetry;
}
- (void)connection:(NSURLConnection *)connection
didSendBodyData:(NSInteger)bytesWritten
totalBytesWritten:(NSInteger)totalBytesWritten
totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite {
@synchronized(self) {
SEL sel = [self sentDataSelector];
[self invokeSentDataCallback:sel
target:delegate_
didSendBodyData:bytesWritten
totalBytesWritten:totalBytesWritten
totalBytesExpectedToWrite:totalBytesExpectedToWrite];
if (sentDataBlock_) {
sentDataBlock_(bytesWritten, totalBytesWritten, totalBytesExpectedToWrite);
}
}
}
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
@synchronized(self) {
#if DEBUG
NSAssert(!hasConnectionEnded_, @"Connection received data after ending");
// The download file handle should be set or the data object allocated
// before the fetch is started.
NSAssert((downloadFileHandle_ == nil) != (downloadedData_ == nil),
@"received data accumulates as either NSData (%d) or"
@" NSFileHandle (%d)",
(downloadedData_ != nil), (downloadFileHandle_ != nil));
#endif
// Hopefully, we'll never see this execute out-of-order, receiving data
// after we've received the finished or failed callback.
if (hasConnectionEnded_) return;
if (downloadFileHandle_ != nil) {
// Append to file
@try {
[downloadFileHandle_ writeData:data];
downloadedLength_ = [downloadFileHandle_ offsetInFile];
}
@catch (NSException *exc) {
// Couldn't write to file, probably due to a full disk
NSDictionary *userInfo = [NSDictionary dictionaryWithObject:[exc reason]
forKey:NSLocalizedDescriptionKey];
NSError *error = [NSError errorWithDomain:kGTMHTTPFetcherStatusDomain
code:kGTMHTTPFetcherErrorFileHandleException
userInfo:userInfo];
[self connection:connection didFailWithError:error];
return;
}
} else {
// append to mutable data
[downloadedData_ appendData:data];
downloadedLength_ = [downloadedData_ length];
}
if (receivedDataSel_) {
[delegate_ performSelector:receivedDataSel_
withObject:self
withObject:downloadedData_];
}
if (receivedDataBlock_) {
receivedDataBlock_(downloadedData_);
}
} // @synchronized(self)
}
// For error 304's ("Not Modified") where we've cached the data, return
// status 200 ("OK") to the caller (but leave the fetcher status as 304)
// and copy the cached data.
//
// For other errors or if there's no cached data, just return the actual status.
- (NSData *)cachedDataForStatus {
if ([self statusCode] == kGTMHTTPFetcherStatusNotModified
&& [fetchHistory_ shouldCacheETaggedData]) {
NSData *cachedData = [fetchHistory_ cachedDataForRequest:request_];
return cachedData;
}
return nil;
}
- (NSInteger)statusAfterHandlingNotModifiedError {
NSInteger status = [self statusCode];
NSData *cachedData = [self cachedDataForStatus];
if (cachedData) {
// Forge the status to pass on to the delegate
status = 200;
// Copy our stored data
if (downloadFileHandle_ != nil) {
@try {
// Downloading to a file handle won't save to the cache (the data is
// likely inappropriately large for caching), but will still read from
// the cache, on the unlikely chance that the response was Not Modified
// and the URL response was indeed present in the cache.
[downloadFileHandle_ truncateFileAtOffset:0];
[downloadFileHandle_ writeData:cachedData];
downloadedLength_ = [downloadFileHandle_ offsetInFile];
}
@catch (NSException *) {
// Failed to write data, likely due to lack of disk space
status = kGTMHTTPFetcherErrorFileHandleException;
}
} else {
[downloadedData_ setData:cachedData];
downloadedLength_ = [cachedData length];
}
}
return status;
}
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
BOOL shouldStopFetching = YES;
BOOL shouldSendStopNotification = NO;
NSError *error = nil;
NSData *downloadedData;
#if !STRIP_GTM_FETCH_LOGGING
BOOL shouldDeferLogging = NO;
#endif
BOOL shouldBeginRetryTimer = NO;
@synchronized(self) {
// We no longer need to cancel the connection
hasConnectionEnded_ = YES;
// Skip caching ETagged results when the data is being saved to a file
if (downloadFileHandle_ == nil) {
[fetchHistory_ updateFetchHistoryWithRequest:request_
response:response_
downloadedData:downloadedData_];
} else {
[fetchHistory_ removeCachedDataForRequest:request_];
}
[[self retain] autorelease]; // in case the callback releases us
NSInteger status = [self statusCode];
if ([self cachedDataForStatus] != nil) {
#if !STRIP_GTM_FETCH_LOGGING
// Log the pre-cache response.
[self logNowWithError:nil];
hasLoggedError_ = YES;
#endif
status = [self statusAfterHandlingNotModifiedError];
}
shouldSendStopNotification = YES;
if (status >= 0 && status < 300) {
// success
if (downloadPath_) {
// Avoid deleting the downloaded file when the fetch stops
[downloadFileHandle_ closeFile];
self.downloadFileHandle = nil;
NSFileManager *fileMgr = [NSFileManager defaultManager];
[fileMgr removeItemAtPath:downloadPath_
error:NULL];
if ([fileMgr moveItemAtPath:temporaryDownloadPath_
toPath:downloadPath_
error:&error]) {
self.temporaryDownloadPath = nil;
}
}
} else {
// unsuccessful
#if !STRIP_GTM_FETCH_LOGGING
if (!hasLoggedError_) {
[self logNowWithError:nil];
hasLoggedError_ = YES;
}
#endif
// Status over 300; retry or notify the delegate of failure
if ([self shouldRetryNowForStatus:status error:nil]) {
// retrying
shouldBeginRetryTimer = YES;
shouldStopFetching = NO;
} else {
NSDictionary *userInfo = nil;
if ([downloadedData_ length] > 0) {
userInfo = [NSDictionary dictionaryWithObject:downloadedData_
forKey:kGTMHTTPFetcherStatusDataKey];
}
error = [NSError errorWithDomain:kGTMHTTPFetcherStatusDomain
code:status
userInfo:userInfo];
}
}
downloadedData = downloadedData_;
#if !STRIP_GTM_FETCH_LOGGING
shouldDeferLogging = shouldDeferResponseBodyLogging_;
#endif
} // @synchronized(self)
if (shouldBeginRetryTimer) {
[self beginRetryTimer];
}
if (shouldSendStopNotification) {
// We want to send the stop notification before calling the delegate's
// callback selector, since the callback selector may release all of
// the fetcher properties that the client is using to track the fetches.
//
// We'll also stop now so that, to any observers watching the notifications,
// it doesn't look like our wait for a retry (which may be long,
// 30 seconds or more) is part of the network activity.
[self sendStopNotificationIfNeeded];
}
if (shouldStopFetching) {
// Call the callbacks (outside of the @synchronized to avoid deadlocks.)
[self invokeFetchCallbacksWithData:downloadedData
error:error];
BOOL shouldRelease = [self shouldReleaseCallbacksUponCompletion];
[self stopFetchReleasingCallbacks:shouldRelease];
}
#if !STRIP_GTM_FETCH_LOGGING
@synchronized(self) {
if (!shouldDeferLogging && !hasLoggedError_) {
[self logNowWithError:nil];
}
}
#endif
}
- (BOOL)shouldReleaseCallbacksUponCompletion {
// A subclass can override this to keep callbacks around after the
// connection has finished successfully
return YES;
}
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
@synchronized(self) {
// Prevent the failure callback from being called twice, since the stopFetch
// call below (either the explicit one at the end of this method, or the
// implicit one when the retry occurs) will release the delegate.
if (connection_ == nil) return;
// If this method was invoked indirectly by cancellation of an authentication
// challenge, defer this until it is called again with the proper error object
if (isCancellingChallenge_) return;
// We no longer need to cancel the connection
hasConnectionEnded_ = YES;
[self logNowWithError:error];
}
// See comment about sendStopNotificationIfNeeded
// in connectionDidFinishLoading:
[self sendStopNotificationIfNeeded];
if ([self shouldRetryNowForStatus:0 error:error]) {
[self beginRetryTimer];
} else {
[[self retain] autorelease]; // in case the callback releases us
[self invokeFetchCallbacksWithData:nil
error:error];
[self stopFetchReleasingCallbacks:YES];
}
}
- (void)logNowWithError:(NSError *)error {
// If the logging category is available, then log the current request,
// response, data, and error
if ([self respondsToSelector:@selector(logFetchWithError:)]) {
[self performSelector:@selector(logFetchWithError:) withObject:error];
}
}
#pragma mark Retries
- (BOOL)isRetryError:(NSError *)error {
struct retryRecord {
NSString *const domain;
int code;
};
struct retryRecord retries[] = {
{ kGTMHTTPFetcherStatusDomain, 408 }, // request timeout
{ kGTMHTTPFetcherStatusDomain, 503 }, // service unavailable
{ kGTMHTTPFetcherStatusDomain, 504 }, // request timeout
{ NSURLErrorDomain, NSURLErrorTimedOut },
{ NSURLErrorDomain, NSURLErrorNetworkConnectionLost },
{ nil, 0 }
};
// NSError's isEqual always returns false for equal but distinct instances
// of NSError, so we have to compare the domain and code values explicitly
for (int idx = 0; retries[idx].domain != nil; idx++) {
if ([[error domain] isEqual:retries[idx].domain]
&& [error code] == retries[idx].code) {
return YES;
}
}
return NO;
}
// shouldRetryNowForStatus:error: returns YES if the user has enabled retries
// and the status or error is one that is suitable for retrying. "Suitable"
// means either the isRetryError:'s list contains the status or error, or the
// user's retrySelector: is present and returns YES when called, or the
// authorizer may be able to fix.
- (BOOL)shouldRetryNowForStatus:(NSInteger)status
error:(NSError *)error {
// Determine if a refreshed authorizer may avoid an authorization error
BOOL shouldRetryForAuthRefresh = NO;
BOOL isFirstAuthError = (authorizer_ != nil)
&& !hasAttemptedAuthRefresh_
&& (status == kGTMHTTPFetcherStatusUnauthorized); // 401
if (isFirstAuthError) {
if ([authorizer_ respondsToSelector:@selector(primeForRefresh)]) {
BOOL hasPrimed = [authorizer_ primeForRefresh];
if (hasPrimed) {
shouldRetryForAuthRefresh = YES;
hasAttemptedAuthRefresh_ = YES;
[request_ setValue:nil forHTTPHeaderField:@"Authorization"];
}
}
}
// Determine if we're doing exponential backoff retries
BOOL shouldDoIntervalRetry = [self isRetryEnabled]
&& ([self nextRetryInterval] < [self maxRetryInterval]);
if (shouldDoIntervalRetry) {
// If an explicit max retry interval was set, we expect repeated backoffs to take
// up to roughly twice that for repeated fast failures. If the initial attempt is
// already more than 3 times the max retry interval, then failures have taken a long time
// (such as from network timeouts) so don't retry again to avoid the app becoming
// unexpectedly unresponsive.
if (maxRetryInterval_ > kUnsetMaxRetryInterval) {
NSTimeInterval maxAllowedIntervalBeforeRetry = maxRetryInterval_ * 3;
NSTimeInterval timeSinceInitialRequest = -[initialRequestDate_ timeIntervalSinceNow];
if (timeSinceInitialRequest > maxAllowedIntervalBeforeRetry) {
shouldDoIntervalRetry = NO;
}
}
}
BOOL willRetry = NO;
BOOL canRetry = shouldRetryForAuthRefresh || shouldDoIntervalRetry;
if (canRetry) {
// Check if this is a retryable error
if (error == nil) {
// Make an error for the status
NSDictionary *userInfo = nil;
if ([downloadedData_ length] > 0) {
userInfo = [NSDictionary dictionaryWithObject:downloadedData_
forKey:kGTMHTTPFetcherStatusDataKey];
}
error = [NSError errorWithDomain:kGTMHTTPFetcherStatusDomain
code:status
userInfo:userInfo];
}
willRetry = shouldRetryForAuthRefresh || [self isRetryError:error];
// If the user has installed a retry callback, consult that
willRetry = [self invokeRetryCallback:retrySel_
target:delegate_
willRetry:willRetry
error:error];
if (retryBlock_) {
willRetry = retryBlock_(willRetry, error);
}
}
return willRetry;
}
- (void)beginRetryTimer {
@synchronized(self) {
if (delegateQueue_ != nil && ![NSThread isMainThread]) {
// A delegate queue is set, so the thread we're running on may not
// have a run loop. We'll defer creating and starting the timer
// until we're on the main thread to ensure it has a run loop.
// (If we weren't supporting 10.5, we could use dispatch_after instead
// of an NSTimer.)
[self performSelectorOnMainThread:_cmd
withObject:nil
waitUntilDone:NO];
return;
}
}
NSTimeInterval nextInterval = [self nextRetryInterval];
NSTimeInterval maxInterval = [self maxRetryInterval];
NSTimeInterval newInterval = MIN(nextInterval, maxInterval);
[self primeRetryTimerWithNewTimeInterval:newInterval];
NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
[nc postNotificationName:kGTMHTTPFetcherRetryDelayStartedNotification
object:self];
}
- (void)primeRetryTimerWithNewTimeInterval:(NSTimeInterval)secs {
[self destroyRetryTimer];
@synchronized(self) {
lastRetryInterval_ = secs;
retryTimer_ = [NSTimer timerWithTimeInterval:secs
target:self
selector:@selector(retryTimerFired:)
userInfo:nil
repeats:NO];
[retryTimer_ retain];
NSRunLoop *timerRL = (self.delegateQueue ?
[NSRunLoop mainRunLoop] : [NSRunLoop currentRunLoop]);
[timerRL addTimer:retryTimer_
forMode:NSDefaultRunLoopMode];
}
}
- (void)retryTimerFired:(NSTimer *)timer {
[self destroyRetryTimer];
@synchronized(self) {
retryCount_++;
[self retryFetch];
}
}
- (void)destroyRetryTimer {
BOOL shouldNotify = NO;
@synchronized(self) {
if (retryTimer_) {
[retryTimer_ invalidate];
[retryTimer_ autorelease];
retryTimer_ = nil;
shouldNotify = YES;
}
} // @synchronized(self)
if (shouldNotify) {
NSNotificationCenter *defaultNC = [NSNotificationCenter defaultCenter];
[defaultNC postNotificationName:kGTMHTTPFetcherRetryDelayStoppedNotification
object:self];
}
}
- (NSUInteger)retryCount {
return retryCount_;
}
- (NSTimeInterval)nextRetryInterval {
// The next wait interval is the factor (2.0) times the last interval,
// but never less than the minimum interval.
NSTimeInterval secs = lastRetryInterval_ * retryFactor_;
secs = MIN(secs, maxRetryInterval_);
secs = MAX(secs, minRetryInterval_);
return secs;
}
- (BOOL)isRetryEnabled {
return isRetryEnabled_;
}
- (void)setRetryEnabled:(BOOL)flag {
if (flag && !isRetryEnabled_) {
// We defer initializing these until the user calls setRetryEnabled
// to avoid using the random number generator if it's not needed.
// However, this means min and max intervals for this fetcher are reset
// as a side effect of calling setRetryEnabled.
//
// Make an initial retry interval random between 1.0 and 2.0 seconds
[self setMinRetryInterval:0.0];
[self setMaxRetryInterval:kUnsetMaxRetryInterval];
[self setRetryFactor:2.0];
lastRetryInterval_ = 0.0;
}
isRetryEnabled_ = flag;
};
- (NSTimeInterval)maxRetryInterval {
return maxRetryInterval_;
}
- (void)setMaxRetryInterval:(NSTimeInterval)secs {
if (secs > 0) {
maxRetryInterval_ = secs;
} else {
maxRetryInterval_ = kUnsetMaxRetryInterval;
}
}
- (double)minRetryInterval {
return minRetryInterval_;
}
- (void)setMinRetryInterval:(NSTimeInterval)secs {
if (secs > 0) {
minRetryInterval_ = secs;
} else {
// Set min interval to a random value between 1.0 and 2.0 seconds
// so that if multiple clients start retrying at the same time, they'll
// repeat at different times and avoid overloading the server
minRetryInterval_ = 1.0 + ((double)(arc4random() & 0x0FFFF) / (double) 0x0FFFF);
}
}
#pragma mark Getters and Setters
@dynamic cookieStorageMethod,
retryEnabled,
maxRetryInterval,
minRetryInterval,
retryCount,
nextRetryInterval,
statusCode,
responseHeaders,
fetchHistory,
userData,
properties;
@synthesize mutableRequest = request_,
allowedInsecureSchemes = allowedInsecureSchemes_,
allowLocalhostRequest = allowLocalhostRequest_,
credential = credential_,
proxyCredential = proxyCredential_,
bodyData = bodyData_,
postStream = postStream_,
delegate = delegate_,
authorizer = authorizer_,
service = service_,
serviceHost = serviceHost_,
servicePriority = servicePriority_,
thread = thread_,
sentDataSelector = sentDataSel_,
receivedDataSelector = receivedDataSel_,
retrySelector = retrySel_,
retryFactor = retryFactor_,
response = response_,
downloadedLength = downloadedLength_,
downloadedData = downloadedData_,
downloadPath = downloadPath_,
temporaryDownloadPath = temporaryDownloadPath_,
downloadFileHandle = downloadFileHandle_,
delegateQueue = delegateQueue_,
runLoopModes = runLoopModes_,
comment = comment_,
log = log_,
cookieStorage = cookieStorage_;
@synthesize completionBlock = completionBlock_,
sentDataBlock = sentDataBlock_,
receivedDataBlock = receivedDataBlock_,
retryBlock = retryBlock_;
@synthesize shouldFetchInBackground = shouldFetchInBackground_;
- (NSInteger)cookieStorageMethod {
return cookieStorageMethod_;
}
- (void)setCookieStorageMethod:(NSInteger)method {
cookieStorageMethod_ = method;
if (method == kGTMHTTPFetcherCookieStorageMethodSystemDefault) {
// System default
[request_ setHTTPShouldHandleCookies:YES];
// No need for a cookie storage object
self.cookieStorage = nil;
} else {
// Not system default
[request_ setHTTPShouldHandleCookies:NO];
if (method == kGTMHTTPFetcherCookieStorageMethodStatic) {
// Store cookies in the static array
NSAssert(gGTMFetcherStaticCookieStorage != nil,
@"cookie storage requires GTMHTTPFetchHistory");
self.cookieStorage = gGTMFetcherStaticCookieStorage;
} else if (method == kGTMHTTPFetcherCookieStorageMethodFetchHistory) {
// store cookies in the fetch history
self.cookieStorage = [fetchHistory_ cookieStorage];
} else {
// kGTMHTTPFetcherCookieStorageMethodNone - ignore cookies
self.cookieStorage = nil;
}
}
}
+ (id <GTMCookieStorageProtocol>)staticCookieStorage {
return gGTMFetcherStaticCookieStorage;
}
+ (BOOL)doesSupportSentDataCallback {
#if GTM_IPHONE
// NSURLConnection's didSendBodyData: delegate support appears to be
// available starting in iPhone OS 3.0
return (NSFoundationVersionNumber >= 678.47);
#else
// Per WebKit's MaxFoundationVersionWithoutdidSendBodyDataDelegate
//
// Indicates if NSURLConnection will invoke the didSendBodyData: delegate
// method
return (NSFoundationVersionNumber > 677.21);
#endif
}
- (id <GTMHTTPFetchHistoryProtocol>)fetchHistory {
return fetchHistory_;
}
- (void)setFetchHistory:(id <GTMHTTPFetchHistoryProtocol>)fetchHistory {
[fetchHistory_ autorelease];
fetchHistory_ = [fetchHistory retain];
if (fetchHistory_ != nil) {
// set the fetch history's cookie array to be the cookie store
[self setCookieStorageMethod:kGTMHTTPFetcherCookieStorageMethodFetchHistory];
} else {
// The fetch history was removed
if (cookieStorageMethod_ == kGTMHTTPFetcherCookieStorageMethodFetchHistory) {
// Fall back to static storage
[self setCookieStorageMethod:kGTMHTTPFetcherCookieStorageMethodStatic];
}
}
}
- (id)userData {
@synchronized(self) {
return [[userData_ retain] autorelease];
}
}
- (void)setUserData:(id)theObj {
@synchronized(self) {
[userData_ autorelease];
userData_ = [theObj retain];
}
}
- (void)setProperties:(NSMutableDictionary *)dict {
@synchronized(self) {
[properties_ autorelease];
// This copies rather than retains the parameter for compatiblity with
// an earlier version that took an immutable parameter and copied it.
properties_ = [dict mutableCopy];
}
}
- (NSMutableDictionary *)properties {
@synchronized(self) {
return [[properties_ retain] autorelease];
}
}
- (void)setProperty:(id)obj forKey:(NSString *)key {
@synchronized(self) {
if (properties_ == nil && obj != nil) {
[self setProperties:[NSMutableDictionary dictionary]];
}
[properties_ setValue:obj forKey:key];
}
}
- (id)propertyForKey:(NSString *)key {
@synchronized(self) {
return [[[properties_ objectForKey:key] retain] autorelease];
}
}
- (void)addPropertiesFromDictionary:(NSDictionary *)dict {
@synchronized(self) {
if (properties_ == nil && dict != nil) {
[self setProperties:[[dict mutableCopy] autorelease]];
} else {
[properties_ addEntriesFromDictionary:dict];
}
}
}
- (NSData *)postData {
#if DEBUG
NSLog(@"*** GTMHTTPFetcher: postData is deprecated; use bodyData property instead ***");
#endif
return self.bodyData;
}
- (void)setPostData:(NSData *)data {
#if DEBUG
NSLog(@"*** GTMHTTPFetcher: postData is deprecated; use bodyData property instead ***");
#endif
self.bodyData = data;
}
- (void)setCommentWithFormat:(id)format, ... {
#if !STRIP_GTM_FETCH_LOGGING
NSString *result = format;
if (format) {
va_list argList;
va_start(argList, format);
result = [[[NSString alloc] initWithFormat:format
arguments:argList] autorelease];
va_end(argList);
}
[self setComment:result];
#endif
}
+ (Class)connectionClass {
if (gGTMFetcherConnectionClass == nil) {
gGTMFetcherConnectionClass = [NSURLConnection class];
}
return gGTMFetcherConnectionClass;
}
+ (void)setConnectionClass:(Class)theClass {
gGTMFetcherConnectionClass = theClass;
}
#if STRIP_GTM_FETCH_LOGGING
+ (void)setLoggingEnabled:(BOOL)flag {
}
+ (BOOL)isLoggingEnabled {
return NO;
}
#endif // STRIP_GTM_FETCH_LOGGING
@end
void GTMAssertSelectorNilOrImplementedWithArgs(id obj, SEL sel, ...) {
// Verify that the object's selector is implemented with the proper
// number and type of arguments
#if DEBUG
va_list argList;
va_start(argList, sel);
if (obj && sel) {
// Check that the selector is implemented
if (![obj respondsToSelector:sel]) {
NSLog(@"\"%@\" selector \"%@\" is unimplemented or misnamed",
NSStringFromClass([obj class]),
NSStringFromSelector(sel));
NSCAssert(0, @"callback selector unimplemented or misnamed");
} else {
const char *expectedArgType;
unsigned int argCount = 2; // skip self and _cmd
NSMethodSignature *sig = [obj methodSignatureForSelector:sel];
// Check that each expected argument is present and of the correct type
while ((expectedArgType = va_arg(argList, const char*)) != 0) {
if ([sig numberOfArguments] > argCount) {
const char *foundArgType = [sig getArgumentTypeAtIndex:argCount];
if(0 != strncmp(foundArgType, expectedArgType, strlen(expectedArgType))) {
NSLog(@"\"%@\" selector \"%@\" argument %d should be type %s",
NSStringFromClass([obj class]),
NSStringFromSelector(sel), (argCount - 2), expectedArgType);
NSCAssert(0, @"callback selector argument type mistake");
}
}
argCount++;
}
// Check that the proper number of arguments are present in the selector
if (argCount != [sig numberOfArguments]) {
NSLog( @"\"%@\" selector \"%@\" should have %d arguments",
NSStringFromClass([obj class]),
NSStringFromSelector(sel), (argCount - 2));
NSCAssert(0, @"callback selector arguments incorrect");
}
}
}
va_end(argList);
#endif
}
NSString *GTMCleanedUserAgentString(NSString *str) {
// Reference http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html
// and http://www-archive.mozilla.org/build/user-agent-strings.html
if (str == nil) return nil;
NSMutableString *result = [NSMutableString stringWithString:str];
// Replace spaces and commas with underscores
[result replaceOccurrencesOfString:@" "
withString:@"_"
options:0
range:NSMakeRange(0, [result length])];
[result replaceOccurrencesOfString:@","
withString:@"_"
options:0
range:NSMakeRange(0, [result length])];
// Delete http token separators and remaining whitespace
static NSCharacterSet *charsToDelete = nil;
if (charsToDelete == nil) {
// Make a set of unwanted characters
NSString *const kSeparators = @"()<>@;:\\\"/[]?={}";
NSMutableCharacterSet *mutableChars;
mutableChars = [[[NSCharacterSet whitespaceAndNewlineCharacterSet] mutableCopy] autorelease];
[mutableChars addCharactersInString:kSeparators];
charsToDelete = [mutableChars copy]; // hang on to an immutable copy
}
while (1) {
NSRange separatorRange = [result rangeOfCharacterFromSet:charsToDelete];
if (separatorRange.location == NSNotFound) break;
[result deleteCharactersInRange:separatorRange];
};
return result;
}
NSString *GTMSystemVersionString(void) {
NSString *systemString = @"";
#if TARGET_OS_MAC && !TARGET_OS_IPHONE
// Mac build
static NSString *savedSystemString = nil;
if (savedSystemString == nil) {
// With Gestalt inexplicably deprecated in 10.8, we're reduced to reading
// the system plist file.
NSString *const kPath = @"/System/Library/CoreServices/SystemVersion.plist";
NSDictionary *plist = [NSDictionary dictionaryWithContentsOfFile:kPath];
NSString *versString = [plist objectForKey:@"ProductVersion"];
if ([versString length] == 0) {
versString = @"10.?.?";
}
savedSystemString = [[NSString alloc] initWithFormat:@"MacOSX/%@", versString];
}
systemString = savedSystemString;
#elif TARGET_OS_IPHONE
// Compiling against the iPhone SDK
static NSString *savedSystemString = nil;
if (savedSystemString == nil) {
// Avoid the slowness of calling currentDevice repeatedly on the iPhone
UIDevice* currentDevice = [UIDevice currentDevice];
NSString *model = [currentDevice model];
NSString *cleanedModel = GTMCleanedUserAgentString(model);
NSString *systemVersion = [currentDevice systemVersion];
#if TARGET_IPHONE_SIMULATOR
NSString *hardwareModel = @"sim";
#else
NSString *hardwareModel;
struct utsname unameRecord;
if (uname(&unameRecord) == 0) {
NSString *machineName = [NSString stringWithCString:unameRecord.machine
encoding:NSUTF8StringEncoding];
hardwareModel = GTMCleanedUserAgentString(machineName);
} else {
hardwareModel = @"unk";
}
#endif
savedSystemString = [[NSString alloc] initWithFormat:@"%@/%@ hw/%@",
cleanedModel, systemVersion, hardwareModel];
// Example: iPod_Touch/2.2 hw/iPod1_1
}
systemString = savedSystemString;
#elif (GTL_IPHONE || GDATA_IPHONE)
// Compiling iOS libraries against the Mac SDK
systemString = @"iPhone/x.x";
#elif defined(_SYS_UTSNAME_H)
// Foundation-only build
struct utsname unameRecord;
uname(&unameRecord);
systemString = [NSString stringWithFormat:@"%s/%s",
unameRecord.sysname, unameRecord.release]; // "Darwin/8.11.1"
#endif
return systemString;
}
// Return a generic name and version for the current application; this avoids
// anonymous server transactions.
NSString *GTMApplicationIdentifier(NSBundle *bundle) {
@synchronized([GTMHTTPFetcher class]) {
static NSMutableDictionary *sAppIDMap = nil;
// If there's a bundle ID, use that; otherwise, use the process name
if (bundle == nil) {
bundle = [NSBundle mainBundle];
}
NSString *bundleID = [bundle bundleIdentifier];
if (bundleID == nil) {
bundleID = @"";
}
NSString *identifier = [sAppIDMap objectForKey:bundleID];
if (identifier) return identifier;
// Apps may add a string to the info.plist to uniquely identify different builds.
identifier = [bundle objectForInfoDictionaryKey:@"GTMUserAgentID"];
if ([identifier length] == 0) {
if ([bundleID length] > 0) {
identifier = bundleID;
} else {
// Fall back on the procname, prefixed by "proc" to flag that it's
// autogenerated and perhaps unreliable
NSString *procName = [[NSProcessInfo processInfo] processName];
identifier = [NSString stringWithFormat:@"proc_%@", procName];
}
}
// Clean up whitespace and special characters
identifier = GTMCleanedUserAgentString(identifier);
// If there's a version number, append that
NSString *version = [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"];
if ([version length] == 0) {
version = [bundle objectForInfoDictionaryKey:@"CFBundleVersion"];
}
// Clean up whitespace and special characters
version = GTMCleanedUserAgentString(version);
// Glue the two together (cleanup done above or else cleanup would strip the
// slash)
if ([version length] > 0) {
identifier = [identifier stringByAppendingFormat:@"/%@", version];
}
if (sAppIDMap == nil) {
sAppIDMap = [[NSMutableDictionary alloc] init];
}
[sAppIDMap setObject:identifier forKey:bundleID];
return identifier;
}
}