diff options
Diffstat (limited to 'example/common/gtm-oauth2/HTTPFetcher/GTMHTTPFetcher.m')
-rwxr-xr-x | example/common/gtm-oauth2/HTTPFetcher/GTMHTTPFetcher.m | 1956 |
1 files changed, 1956 insertions, 0 deletions
diff --git a/example/common/gtm-oauth2/HTTPFetcher/GTMHTTPFetcher.m b/example/common/gtm-oauth2/HTTPFetcher/GTMHTTPFetcher.m new file mode 100755 index 00000000..ebbdd0de --- /dev/null +++ b/example/common/gtm-oauth2/HTTPFetcher/GTMHTTPFetcher.m @@ -0,0 +1,1956 @@ +/* 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.m +// + +#define GTMHTTPFETCHER_DEFINE_GLOBALS 1 + +#import "GTMHTTPFetcher.h" + +#if GTM_BACKGROUND_FETCHING +#import <UIKit/UIKit.h> +#endif + +static id <GTMCookieStorageProtocol> gGTMFetcherStaticCookieStorage = nil; +static Class gGTMFetcherConnectionClass = nil; + +// 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 kCallbackData = @"data"; +static NSString *const kCallbackError = @"error"; + +// +// GTMHTTPFetcher +// + +@interface GTMHTTPFetcher () + +@property (copy) NSString *temporaryDownloadPath; +@property (retain) id <GTMCookieStorageProtocol> cookieStorage; +@property (readwrite, retain) NSData *downloadedData; +#if NS_BLOCKS_AVAILABLE +@property (copy) void (^completionBlock)(NSData *, NSError *); +#endif + +- (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)invokeFetchCallback:(SEL)sel + target:(id)target + data:(NSData *)data + error:(NSError *)error; +- (void)invokeFetchCallbacksOnDelegateQueueWithData:(NSData *)data + error:(NSError *)error; +- (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; +@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]; + } + } +} + +- (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]; + [postData_ release]; + [postStream_ release]; + [loggedStreamData_ release]; + [response_ release]; +#if NS_BLOCKS_AVAILABLE + [completionBlock_ release]; + [receivedDataBlock_ release]; + [sentDataBlock_ release]; + [retryBlock_ release]; +#endif + [userData_ release]; + [properties_ release]; + [delegateQueue_ release]; + [runLoopModes_ release]; + [fetchHistory_ release]; + [cookieStorage_ release]; + [authorizer_ release]; + [service_ release]; + [serviceHost_ release]; + [thread_ release]; + [retryTimer_ release]; + [comment_ release]; + [log_ 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; + } + + if (request_ == nil || [request_ URL] == nil) { + NSAssert(request_ != nil, @"beginFetchWithDelegate requires a request with a URL"); + goto CannotBeginFetch; + } + + self.downloadedData = nil; + downloadedLength_ = 0; + + 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 (postData_ || postStream_) { + if (isEffectiveHTTPGet) { + [request_ setHTTPMethod:@"POST"]; + isEffectiveHTTPGet = NO; + } + + if (postData_) { + [request_ setHTTPBody:postData_]; + } 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; + } + +#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 + + // 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]; + } +} + +#if NS_BLOCKS_AVAILABLE +- (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]; +} +#endif + +- (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; + +#if NS_BLOCKS_AVAILABLE + self.completionBlock = nil; + self.sentDataBlock = nil; + self.receivedDataBlock = nil; + self.retryBlock = nil; +#endif +} + +// 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_ +#if NS_BLOCKS_AVAILABLE + || completionBlock_ != nil +#endif + || 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; +#if NS_BLOCKS_AVAILABLE + void (^block)(NSData *, NSError *); +#endif + @synchronized(self) { + target = delegate_; + sel = finishedSel_; + block = completionBlock_; + } + + [[self retain] autorelease]; // In case the callback releases us + + [self invokeFetchCallback:sel + target:target + data:data + error:error]; + +#if NS_BLOCKS_AVAILABLE + if (block) { + block(data, error); + } +#endif +} + +- (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]; + NSInvocationOperation *op = + [[[NSInvocationOperation alloc] initWithTarget:self + selector:@selector(invokeOnQueueWithDictionary:) + object:dict] autorelease]; + [delegateQueue_ addOperation:op]; +} + +- (void)invokeOnQueueWithDictionary:(NSDictionary *)dict { + NSData *data = [dict objectForKey:kCallbackData]; + NSError *error = [dict objectForKey:kCallbackError]; + + [self invokeFetchCallbacksWithData: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 NS_BLOCKS_AVAILABLE + if (sentDataBlock_) { + sentDataBlock_(bytesWritten, totalBytesWritten, totalBytesExpectedToWrite); + } +#endif + } +} + +- (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 NS_BLOCKS_AVAILABLE + if (receivedDataBlock_) { + receivedDataBlock_(downloadedData_); + } +#endif + } // @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; + BOOL hasLogged = 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) { + // Log the pre-cache response. + [self logNowWithError:nil]; + hasLogged = YES; + 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 (!hasLogged) { + [self logNowWithError:nil]; + hasLogged = YES; + } + // 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]; + } + + @synchronized(self) { + BOOL shouldLogNow = !hasLogged; +#if !STRIP_GTM_FETCH_LOGGING + if (shouldDeferLogging) shouldLogNow = NO; +#endif + if (shouldLogNow) { + [self logNowWithError:nil]; + } + } +} + +- (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]); + + 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 NS_BLOCKS_AVAILABLE + if (retryBlock_) { + willRetry = retryBlock_(willRetry, error); + } +#endif + } + 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_, + credential = credential_, + proxyCredential = proxyCredential_, + postData = postData_, + 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_; + +#if NS_BLOCKS_AVAILABLE +@synthesize completionBlock = completionBlock_, + sentDataBlock = sentDataBlock_, + receivedDataBlock = receivedDataBlock_, + retryBlock = retryBlock_; +#endif + +@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_; + } +} + +- (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_; + } +} + +- (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]; + } +} + +- (void)addPropertiesFromDictionary:(NSDictionary *)dict { + @synchronized(self) { + if (properties_ == nil && dict != nil) { + [self setProperties:[[dict mutableCopy] autorelease]]; + } else { + [properties_ addEntriesFromDictionary:dict]; + } + } +} + +- (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 { +} +#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 with underscores + [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 *rawModel = [currentDevice model]; + NSString *model = GTMCleanedUserAgentString(rawModel); + + NSString *systemVersion = [currentDevice systemVersion]; + + savedSystemString = [[NSString alloc] initWithFormat:@"%@/%@", + model, systemVersion]; // "iPod_Touch/2.2" + } + 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) { + static NSString *sAppID = nil; + if (sAppID != nil) return sAppID; + + // If there's a bundle ID, use that; otherwise, use the process name + if (bundle == nil) { + bundle = [NSBundle mainBundle]; + } + + NSString *identifier; + NSString *bundleID = [bundle bundleIdentifier]; + 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]; + } + + sAppID = [identifier copy]; + return sAppID; +} |