AutorÃa | Ultima modificación | Ver Log |
/** Copyright 2020 Google LLC** 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.*/#import "GoogleDataTransport/GDTCCTLibrary/Private/GDTCCTUploadOperation.h"#if __has_include(<FBLPromises/FBLPromises.h>)#import <FBLPromises/FBLPromises.h>#else#import "FBLPromises.h"#endif#import "GoogleDataTransport/GDTCORLibrary/Internal/GDTCORPlatform.h"#import "GoogleDataTransport/GDTCORLibrary/Internal/GDTCORRegistrar.h"#import "GoogleDataTransport/GDTCORLibrary/Internal/GDTCORStorageProtocol.h"#import "GoogleDataTransport/GDTCORLibrary/Private/GDTCORUploadBatch.h"#import "GoogleDataTransport/GDTCORLibrary/Public/GoogleDataTransport/GDTCORConsoleLogger.h"#import "GoogleDataTransport/GDTCORLibrary/Public/GoogleDataTransport/GDTCOREvent.h"#import <nanopb/pb.h>#import <nanopb/pb_decode.h>#import <nanopb/pb_encode.h>#import <GoogleUtilities/GULURLSessionDataResponse.h>#import <GoogleUtilities/NSURLSession+GULPromises.h>#import "GoogleDataTransport/GDTCCTLibrary/Private/GDTCCTCompressionHelper.h"#import "GoogleDataTransport/GDTCCTLibrary/Private/GDTCCTNanopbHelpers.h"#import "GoogleDataTransport/GDTCCTLibrary/Protogen/nanopb/cct.nanopb.h"NS_ASSUME_NONNULL_BEGIN#ifdef GDTCOR_VERSION#define STR(x) STR_EXPAND(x)#define STR_EXPAND(x) #xstatic NSString *const kGDTCCTSupportSDKVersion = @STR(GDTCOR_VERSION);#elsestatic NSString *const kGDTCCTSupportSDKVersion = @"UNKNOWN";#endif // GDTCOR_VERSIONtypedef void (^GDTCCTUploaderURLTaskCompletion)(NSNumber *batchID,NSSet<GDTCOREvent *> *_Nullable events,NSData *_Nullable data,NSURLResponse *_Nullable response,NSError *_Nullable error);typedef void (^GDTCCTUploaderEventBatchBlock)(NSNumber *_Nullable batchID,NSSet<GDTCOREvent *> *_Nullable events);@interface GDTCCTUploadOperation () <NSURLSessionDelegate>/// The properties to store parameters passed in the initializer. See the initialized docs for/// details.@property(nonatomic, readonly) GDTCORTarget target;@property(nonatomic, readonly) GDTCORUploadConditions conditions;@property(nonatomic, readonly) NSURL *uploadURL;@property(nonatomic, readonly) id<GDTCORStoragePromiseProtocol> storage;@property(nonatomic, readonly) id<GDTCCTUploadMetadataProvider> metadataProvider;/** The URL session that will attempt upload. */@property(nonatomic) NSURLSession *uploaderSession;/// NSOperation state properties implementation.@property(nonatomic, readwrite, getter=isExecuting) BOOL executing;@property(nonatomic, readwrite, getter=isFinished) BOOL finished;@property(nonatomic, readwrite) BOOL uploadAttempted;@end@implementation GDTCCTUploadOperation- (instancetype)initWithTarget:(GDTCORTarget)targetconditions:(GDTCORUploadConditions)conditionsuploadURL:(NSURL *)uploadURLqueue:(dispatch_queue_t)queuestorage:(id<GDTCORStoragePromiseProtocol>)storagemetadataProvider:(id<GDTCCTUploadMetadataProvider>)metadataProvider {self = [super init];if (self) {_uploaderQueue = queue;_target = target;_conditions = conditions;_uploadURL = uploadURL;_storage = storage;_metadataProvider = metadataProvider;}return self;}- (NSURLSession *)uploaderSessionCreateIfNeeded {if (_uploaderSession == nil) {NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];_uploaderSession = [NSURLSession sessionWithConfiguration:configdelegate:selfdelegateQueue:nil];}return _uploaderSession;}- (void)uploadTarget:(GDTCORTarget)target withConditions:(GDTCORUploadConditions)conditions {__block GDTCORBackgroundIdentifier backgroundTaskID = GDTCORBackgroundIdentifierInvalid;dispatch_block_t backgroundTaskCompletion = ^{// End the background task if there was one.if (backgroundTaskID != GDTCORBackgroundIdentifierInvalid) {[[GDTCORApplication sharedApplication] endBackgroundTask:backgroundTaskID];backgroundTaskID = GDTCORBackgroundIdentifierInvalid;}};backgroundTaskID = [[GDTCORApplication sharedApplication]beginBackgroundTaskWithName:@"GDTCCTUploader-upload"expirationHandler:^{if (backgroundTaskID != GDTCORBackgroundIdentifierInvalid) {// Cancel the upload and complete delivery.[self.currentTask cancel];// End the background task.backgroundTaskCompletion();}}];id<GDTCORStoragePromiseProtocol> storage = self.storage;// 1. Check if the conditions for the target are suitable.[self isReadyToUploadTarget:target conditions:conditions].thenOn(self.uploaderQueue,^id(id result) {// 2. Remove previously attempted batchesreturn [storage removeAllBatchesForTarget:target deleteEvents:NO];}).thenOn(self.uploaderQueue,^FBLPromise<NSNumber *> *(id result) {// There may be a big amount of events stored, so creating a batch may be an// expensive operation.// 3. Do a lightweight check if there are any events for the target first to// finish early if there are no.return [storage hasEventsForTarget:target];}).validateOn(self.uploaderQueue,^BOOL(NSNumber *hasEvents) {// Stop operation if there are no events to upload.return hasEvents.boolValue;}).thenOn(self.uploaderQueue,^FBLPromise<GDTCORUploadBatch *> *(id result) {if (self.isCancelled) {return nil;}// 4. Fetch events to upload.GDTCORStorageEventSelector *eventSelector = [self eventSelectorTarget:targetwithConditions:conditions];return [storage batchWithEventSelector:eventSelectorbatchExpiration:[NSDate dateWithTimeIntervalSinceNow:600]];}).validateOn(self.uploaderQueue,^BOOL(GDTCORUploadBatch *batch) {// 5. Validate batch.return batch.batchID != nil && batch.events.count > 0;}).thenOn(self.uploaderQueue,^FBLPromise *(GDTCORUploadBatch *batch) {// A non-empty batch has been created, consider it as an upload attempt.self.uploadAttempted = YES;// 6. Perform upload URL request.return [self sendURLRequestWithBatch:batch target:target storage:storage];}).thenOn(self.uploaderQueue,^id(id result) {// 7. Finish operation.[self finishOperation];backgroundTaskCompletion();return nil;}).catchOn(self.uploaderQueue, ^(NSError *error) {// TODO: Maybe report the error to the client.[self finishOperation];backgroundTaskCompletion();});}#pragma mark - Upload implementation details/** Sends URL request to upload the provided batch and handle the response. */- (FBLPromise<NSNull *> *)sendURLRequestWithBatch:(GDTCORUploadBatch *)batchtarget:(GDTCORTarget)targetstorage:(id<GDTCORStoragePromiseProtocol>)storage {NSNumber *batchID = batch.batchID;// 1. Send URL request.return [self sendURLRequestWithBatch:batch target:target].thenOn(self.uploaderQueue,^FBLPromise<NSNull *> *(GULURLSessionDataResponse *response) {// 2. Parse response and update the next upload time if can.[self updateNextUploadTimeWithResponse:response forTarget:target];// 3. Cleanup batch.// Only retry if one of these codes is returned:// 429 - Too many requests;// 5xx - Server errors.NSInteger statusCode = response.HTTPResponse.statusCode;if (statusCode == 429 || (statusCode >= 500 && statusCode < 600)) {// Move the events back to the main storage to be uploaded on the next attempt.return [storage removeBatchWithID:batchID deleteEvents:NO];} else {if (statusCode >= 200 && statusCode <= 300) {GDTCORLogDebug(@"CCT: batch %@ delivered", batchID);} else {GDTCORLogDebug(@"CCT: batch %@ was rejected by the server and will be deleted with all events",batchID);}// The events are either delivered or unrecoverable broken, so remove the batch with// events.return [storage removeBatchWithID:batch.batchID deleteEvents:YES];}}).recoverOn(self.uploaderQueue, ^id(NSError *error) {// In the case of a network error move the events back to the main storage to be uploaded on// the next attempt.return [storage removeBatchWithID:batchID deleteEvents:NO];});}/** Composes and sends URL request. */- (FBLPromise<GULURLSessionDataResponse *> *)sendURLRequestWithBatch:(GDTCORUploadBatch *)batchtarget:(GDTCORTarget)target {return [FBLPromiseonQueue:self.uploaderQueuedo:^NSURLRequest * {// 1. Prepare URL request.NSData *requestProtoData = [self constructRequestProtoWithEvents:batch.events];NSData *gzippedData = [GDTCCTCompressionHelper gzippedData:requestProtoData];BOOL usingGzipData =gzippedData != nil && gzippedData.length < requestProtoData.length;NSData *dataToSend = usingGzipData ? gzippedData : requestProtoData;NSURLRequest *request = [self constructRequestWithURL:self.uploadURLforTarget:targetdata:dataToSend];GDTCORLogDebug(@"CTT: request containing %lu events for batch: %@ for target: "@"%ld created: %@",(unsigned long)batch.events.count, batch.batchID, (long)target,request);return request;}].thenOn(self.uploaderQueue,^FBLPromise<GULURLSessionDataResponse *> *(NSURLRequest *request) {// 2. Send URL request.return[[self uploaderSessionCreateIfNeeded] gul_dataTaskPromiseWithRequest:request];}).thenOn(self.uploaderQueue,^GULURLSessionDataResponse *(GULURLSessionDataResponse *response) {// Invalidate session to release the delegate (which is `self`) to break the retain// cycle.[self.uploaderSession finishTasksAndInvalidate];return response;}).recoverOn(self.uploaderQueue, ^id(NSError *error) {// Invalidate session to release the delegate (which is `self`) to break the retain cycle.[self.uploaderSession finishTasksAndInvalidate];// Re-throw the error.return error;});}/** Parses server response and update next upload time for the specified target based on it. */- (void)updateNextUploadTimeWithResponse:(GULURLSessionDataResponse *)responseforTarget:(GDTCORTarget)target {GDTCORClock *futureUploadTime;if (response.HTTPBody) {NSError *decodingError;gdt_cct_LogResponse logResponse = GDTCCTDecodeLogResponse(response.HTTPBody, &decodingError);if (!decodingError && logResponse.has_next_request_wait_millis) {GDTCORLogDebug(@"CCT: The backend responded asking to not upload for %lld millis from now.",logResponse.next_request_wait_millis);futureUploadTime =[GDTCORClock clockSnapshotInTheFuture:logResponse.next_request_wait_millis];} else if (decodingError) {GDTCORLogDebug(@"There was a response decoding error: %@", decodingError);}pb_release(gdt_cct_LogResponse_fields, &logResponse);}// If no futureUploadTime was parsed from the response body, then check// [Retry-After](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header.if (!futureUploadTime) {NSString *retryAfterHeader = response.HTTPResponse.allHeaderFields[@"Retry-After"];if (retryAfterHeader.length > 0) {NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];formatter.numberStyle = NSNumberFormatterDecimalStyle;NSNumber *retryAfterSeconds = [formatter numberFromString:retryAfterHeader];if (retryAfterSeconds != nil) {uint64_t retryAfterMillis = retryAfterSeconds.unsignedIntegerValue * 1000u;futureUploadTime = [GDTCORClock clockSnapshotInTheFuture:retryAfterMillis];}}}if (!futureUploadTime) {GDTCORLogDebug(@"%@", @"CCT: The backend response failed to parse, so the next request "@"won't occur until 15 minutes from now");// 15 minutes from now.futureUploadTime = [GDTCORClock clockSnapshotInTheFuture:15 * 60 * 1000];}[self.metadataProvider setNextUploadTime:futureUploadTime forTarget:target];}#pragma mark - Private helper methods/** @return A resolved promise if is ready and a rejected promise if not. */- (FBLPromise<NSNull *> *)isReadyToUploadTarget:(GDTCORTarget)targetconditions:(GDTCORUploadConditions)conditions {FBLPromise<NSNull *> *promise = [FBLPromise pendingPromise];if ([self readyToUploadTarget:target conditions:conditions]) {[promise fulfill:[NSNull null]];} else {NSString *reason =[NSString stringWithFormat:@"Target %ld is not ready to upload with condition: %ld",(long)target, (long)conditions];[promise reject:[self genericRejectedPromiseErrorWithReason:reason]];}return promise;}// TODO: Move to a separate class/extension/file when needed in other files./** Returns an error object with the specified failure reason. */- (NSError *)genericRejectedPromiseErrorWithReason:(NSString *)reason {return [NSError errorWithDomain:@"GDTCCTUploader"code:-1userInfo:@{NSLocalizedFailureReasonErrorKey : reason}];}/** Returns if the specified target is ready to be uploaded based on the specified conditions. */- (BOOL)readyToUploadTarget:(GDTCORTarget)target conditions:(GDTCORUploadConditions)conditions {// Not ready to upload with no network connection.// TODO: Reconsider using reachability to prevent an upload attempt.// See https://developer.apple.com/videos/play/wwdc2019/712/ (49:40) for more details.if (conditions & GDTCORUploadConditionNoNetwork) {GDTCORLogDebug(@"%@", @"CCT: Not ready to upload without a network connection.");return NO;}// Upload events with no additional conditions if high priority.if ((conditions & GDTCORUploadConditionHighPriority) == GDTCORUploadConditionHighPriority) {GDTCORLogDebug(@"%@", @"CCT: a high priority event is allowing an upload");return YES;}// Check next upload time for the target.BOOL isAfterNextUploadTime = YES;GDTCORClock *nextUploadTime = [self.metadataProvider nextUploadTimeForTarget:target];if (nextUploadTime) {isAfterNextUploadTime = [[GDTCORClock snapshot] isAfter:nextUploadTime];}if (isAfterNextUploadTime) {GDTCORLogDebug(@"CCT: can upload to target %ld because the request wait time has transpired",(long)target);} else {GDTCORLogDebug(@"CCT: can't upload to target %ld because the backend asked to wait",(long)target);}return isAfterNextUploadTime;}/** Constructs data given an upload package.** @param events The events used to construct the request proto bytes.* @return Proto bytes representing a gdt_cct_LogRequest object.*/- (nonnull NSData *)constructRequestProtoWithEvents:(NSSet<GDTCOREvent *> *)events {// Segment the log events by log type.NSMutableDictionary<NSString *, NSMutableSet<GDTCOREvent *> *> *logMappingIDToLogSet =[[NSMutableDictionary alloc] init];[events enumerateObjectsUsingBlock:^(GDTCOREvent *_Nonnull event, BOOL *_Nonnull stop) {NSMutableSet *logSet = logMappingIDToLogSet[event.mappingID];logSet = logSet ? logSet : [[NSMutableSet alloc] init];[logSet addObject:event];logMappingIDToLogSet[event.mappingID] = logSet;}];gdt_cct_BatchedLogRequest batchedLogRequest =GDTCCTConstructBatchedLogRequest(logMappingIDToLogSet);NSData *data = GDTCCTEncodeBatchedLogRequest(&batchedLogRequest);pb_release(gdt_cct_BatchedLogRequest_fields, &batchedLogRequest);return data ? data : [[NSData alloc] init];}/** Constructs a request to the given URL and target with the specified request body data.** @param target The target backend to send the request to.* @param data The request body data.* @return A new NSURLRequest ready to be sent to FLL.*/- (nullable NSURLRequest *)constructRequestWithURL:(NSURL *)URLforTarget:(GDTCORTarget)targetdata:(NSData *)data {if (data == nil || data.length == 0) {GDTCORLogDebug(@"There was no data to construct a request for target %ld.", (long)target);return nil;}NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL];NSString *targetString;switch (target) {case kGDTCORTargetCCT:targetString = @"cct";break;case kGDTCORTargetFLL:targetString = @"fll";break;case kGDTCORTargetCSH:targetString = @"csh";break;case kGDTCORTargetINT:targetString = @"int";break;default:targetString = @"unknown";break;}NSString *userAgent =[NSString stringWithFormat:@"datatransport/%@ %@support/%@ apple/", kGDTCORVersion,targetString, kGDTCCTSupportSDKVersion];[request setValue:[self.metadataProvider APIKeyForTarget:target]forHTTPHeaderField:@"X-Goog-Api-Key"];if ([GDTCCTCompressionHelper isGzipped:data]) {[request setValue:@"gzip" forHTTPHeaderField:@"Content-Encoding"];}[request setValue:@"application/x-protobuf" forHTTPHeaderField:@"Content-Type"];[request setValue:@"gzip" forHTTPHeaderField:@"Accept-Encoding"];[request setValue:userAgent forHTTPHeaderField:@"User-Agent"];request.HTTPMethod = @"POST";[request setHTTPBody:data];return request;}/** Creates and returns a storage event selector for the specified target and conditions. */- (GDTCORStorageEventSelector *)eventSelectorTarget:(GDTCORTarget)targetwithConditions:(GDTCORUploadConditions)conditions {if ((conditions & GDTCORUploadConditionHighPriority) == GDTCORUploadConditionHighPriority) {return [GDTCORStorageEventSelector eventSelectorForTarget:target];}NSMutableSet<NSNumber *> *qosTiers = [[NSMutableSet alloc] init];if (conditions & GDTCORUploadConditionWifiData) {[qosTiers addObjectsFromArray:@[@(GDTCOREventQoSFast), @(GDTCOREventQoSWifiOnly), @(GDTCOREventQosDefault),@(GDTCOREventQoSTelemetry), @(GDTCOREventQoSUnknown)]];}if (conditions & GDTCORUploadConditionMobileData) {[qosTiers addObjectsFromArray:@[ @(GDTCOREventQoSFast), @(GDTCOREventQosDefault) ]];}return [[GDTCORStorageEventSelector alloc] initWithTarget:targeteventIDs:nilmappingIDs:nilqosTiers:qosTiers];}#pragma mark - NSURLSessionDelegate- (void)URLSession:(NSURLSession *)sessiontask:(NSURLSessionTask *)taskwillPerformHTTPRedirection:(NSHTTPURLResponse *)responsenewRequest:(NSURLRequest *)requestcompletionHandler:(void (^)(NSURLRequest *_Nullable))completionHandler {if (!completionHandler) {return;}if (response.statusCode == 302 || response.statusCode == 301) {NSURLRequest *newRequest = [self constructRequestWithURL:request.URLforTarget:kGDTCORTargetCCTdata:task.originalRequest.HTTPBody];completionHandler(newRequest);} else {completionHandler(request);}}#pragma mark - NSOperation methods@synthesize executing = _executing;@synthesize finished = _finished;- (BOOL)isFinished {@synchronized(self) {return _finished;}}- (BOOL)isExecuting {@synchronized(self) {return _executing;}}- (BOOL)isAsynchronous {return YES;}- (void)startOperation {@synchronized(self) {[self willChangeValueForKey:@"isExecuting"];[self willChangeValueForKey:@"isFinished"];self->_executing = YES;self->_finished = NO;[self didChangeValueForKey:@"isExecuting"];[self didChangeValueForKey:@"isFinished"];}}- (void)finishOperation {@synchronized(self) {[self willChangeValueForKey:@"isExecuting"];[self willChangeValueForKey:@"isFinished"];self->_executing = NO;self->_finished = YES;[self didChangeValueForKey:@"isExecuting"];[self didChangeValueForKey:@"isFinished"];}}- (void)start {[self startOperation];GDTCORLogDebug(@"Upload operation started: %@", self);[self uploadTarget:self.target withConditions:self.conditions];}- (void)cancel {@synchronized(self) {[super cancel];// If the operation hasn't been started we can set `isFinished = YES` straight away.if (!_executing) {_executing = NO;_finished = YES;}}}@endNS_ASSUME_NONNULL_END