diff --git a/CleverTapSDK.xcodeproj/project.pbxproj b/CleverTapSDK.xcodeproj/project.pbxproj index 6657d798..75193683 100644 --- a/CleverTapSDK.xcodeproj/project.pbxproj +++ b/CleverTapSDK.xcodeproj/project.pbxproj @@ -394,6 +394,7 @@ 6BB778D62BFD26E000A41628 /* CTInAppNotificationDisplayDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 6BB778D52BFD26DF00A41628 /* CTInAppNotificationDisplayDelegate.h */; settings = {ATTRIBUTES = (Private, ); }; }; 6BB778D72BFD26E000A41628 /* CTInAppNotificationDisplayDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 6BB778D52BFD26DF00A41628 /* CTInAppNotificationDisplayDelegate.h */; settings = {ATTRIBUTES = (Private, ); }; }; 6BB778D92BFD277400A41628 /* CTCustomTemplatesManager-Internal.h in Headers */ = {isa = PBXBuildFile; fileRef = 6BB778D82BFD277400A41628 /* CTCustomTemplatesManager-Internal.h */; }; + 6BBF05CE2C58E3FB0047E3D9 /* NSURLSessionMock.m in Sources */ = {isa = PBXBuildFile; fileRef = 6BBF05CD2C58E3FB0047E3D9 /* NSURLSessionMock.m */; }; 6BD334EA2AF2A41F0099E33E /* CTBatchSentDelegateHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = 6BD334E82AF2A41F0099E33E /* CTBatchSentDelegateHelper.h */; }; 6BD334EB2AF2A41F0099E33E /* CTBatchSentDelegateHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = 6BD334E82AF2A41F0099E33E /* CTBatchSentDelegateHelper.h */; }; 6BD334EC2AF2A41F0099E33E /* CTBatchSentDelegateHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 6BD334E92AF2A41F0099E33E /* CTBatchSentDelegateHelper.m */; }; @@ -953,6 +954,8 @@ 6BB778D12BF267B600A41628 /* CTTemplateContextTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CTTemplateContextTest.m; sourceTree = ""; }; 6BB778D52BFD26DF00A41628 /* CTInAppNotificationDisplayDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CTInAppNotificationDisplayDelegate.h; sourceTree = ""; }; 6BB778D82BFD277400A41628 /* CTCustomTemplatesManager-Internal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CTCustomTemplatesManager-Internal.h"; sourceTree = ""; }; + 6BBF05CC2C58E3FB0047E3D9 /* NSURLSessionMock.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSURLSessionMock.h; sourceTree = ""; }; + 6BBF05CD2C58E3FB0047E3D9 /* NSURLSessionMock.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSURLSessionMock.m; sourceTree = ""; }; 6BD334E82AF2A41F0099E33E /* CTBatchSentDelegateHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CTBatchSentDelegateHelper.h; sourceTree = ""; }; 6BD334E92AF2A41F0099E33E /* CTBatchSentDelegateHelper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CTBatchSentDelegateHelper.m; sourceTree = ""; }; 6BD334EF2AF545C70099E33E /* CTInAppStoreTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CTInAppStoreTest.m; sourceTree = ""; }; @@ -1514,6 +1517,8 @@ 6B9E95B22C2868470002D557 /* CTFileDownloadManager+Tests.h */, 6B9E95B32C29C2F30002D557 /* NSFileManagerMock.h */, 6B9E95B42C29C2F30002D557 /* NSFileManagerMock.m */, + 6BBF05CC2C58E3FB0047E3D9 /* NSURLSessionMock.h */, + 6BBF05CD2C58E3FB0047E3D9 /* NSURLSessionMock.m */, ); path = FileDownload; sourceTree = ""; @@ -2495,6 +2500,7 @@ 32394C2529FA272600956058 /* CTValidatorTest.m in Sources */, 6BB778CE2BEE48C300A41628 /* CTCustomTemplateInAppDataTest.m in Sources */, 6BA3B2E82B07E207004E834B /* CTTriggersMatcher+Tests.m in Sources */, + 6BBF05CE2C58E3FB0047E3D9 /* NSURLSessionMock.m in Sources */, 6B32A0A52B9A0F17009ADC57 /* CTCustomTemplateTest.m in Sources */, 4E1F155B276B662C009387AE /* EventDetail.m in Sources */, 6B4A0F912B45EF6D00A42C6D /* CTInAppTriggerManagerTest.m in Sources */, diff --git a/CleverTapSDK/CTConstants.h b/CleverTapSDK/CTConstants.h index 5d953acc..a3820ec0 100644 --- a/CleverTapSDK/CTConstants.h +++ b/CleverTapSDK/CTConstants.h @@ -96,7 +96,8 @@ static NSString *const kCLTAP_COMMAND_DELETE = @"$delete"; #define CLTAP_FILE_URLS_EXPIRY_DICT @"file_urls_expiry_dict" #define CLTAP_FILE_ASSETS_LAST_DELETED_TS @"cs_file_assets_last_deleted_timestamp" #define CLTAP_FILE_EXPIRY_OFFSET (60 * 60 * 24 * 7 * 2) // 2 weeks -#define CLTAP_FILE_RESOURCE_TIME_OUT_INTERVAL 15 +#define CLTAP_FILE_RESOURCE_TIME_OUT_INTERVAL 25 +#define CLTAP_FILE_MAX_CONCURRENCY_COUNT 10 #define CLTAP_FILES_DIRECTORY_NAME @"CleverTap_Files" #pragma mark Constants for App fields diff --git a/CleverTapSDK/FileDownload/CTFileDownloadManager.m b/CleverTapSDK/FileDownload/CTFileDownloadManager.m index 128e6f80..3854e8eb 100644 --- a/CleverTapSDK/FileDownload/CTFileDownloadManager.m +++ b/CleverTapSDK/FileDownload/CTFileDownloadManager.m @@ -10,6 +10,7 @@ @interface CTFileDownloadManager() @property (nonatomic, strong) NSMutableDictionary *> *downloadInProgressHandlers; @property (nonatomic, strong) NSURLSession *session; @property (nonatomic, strong) NSFileManager* fileManager; +@property NSTimeInterval semaphoreTimeout; @end @@ -37,6 +38,8 @@ - (instancetype)initWithConfig:(CleverTapInstanceConfig *)config { NSURLSessionConfiguration *sc = [NSURLSessionConfiguration defaultSessionConfiguration]; sc.timeoutIntervalForRequest = CLTAP_REQUEST_TIME_OUT_INTERVAL; sc.timeoutIntervalForResource = CLTAP_FILE_RESOURCE_TIME_OUT_INTERVAL; + // Timeout of (request timeout + 5) seconds for acquiring semaphore + self.semaphoreTimeout = CLTAP_FILE_RESOURCE_TIME_OUT_INTERVAL + 5; self.session = [NSURLSession sessionWithConfiguration:sc]; self.fileManager = [NSFileManager defaultManager]; @@ -50,9 +53,22 @@ - (void)downloadFiles:(nonnull NSArray *)urls withCompletionBlock:(nonnull CTFilesDownloadCompletedBlock)completion { dispatch_group_t group = dispatch_group_create(); dispatch_queue_t concurrentQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); + dispatch_semaphore_t semaphore = dispatch_semaphore_create(CLTAP_FILE_MAX_CONCURRENCY_COUNT); + NSMutableDictionary *filesDownloadStatus = [NSMutableDictionary new]; for (NSURL *url in urls) { dispatch_group_enter(group); + + dispatch_time_t semaphore_timeout = dispatch_time(DISPATCH_TIME_NOW, + self.semaphoreTimeout * NSEC_PER_SEC); + if (dispatch_semaphore_wait(semaphore, semaphore_timeout) != 0) { + @synchronized (filesDownloadStatus) { + [filesDownloadStatus setObject:@0 forKey:[url absoluteString]]; + } + dispatch_group_leave(group); + continue; // Proceed to next URL + } + @synchronized (self) { BOOL isAlreadyDownloading = [_downloadInProgressUrls containsObject:url]; if (isAlreadyDownloading) { @@ -62,10 +78,11 @@ - (void)downloadFiles:(nonnull NSArray *)urls _downloadInProgressHandlers[url] = [NSMutableArray array]; } [_downloadInProgressHandlers[url] addObject:^(NSURL *completedURL, BOOL success) { - @synchronized (self) { + @synchronized (filesDownloadStatus) { [filesDownloadStatus setObject:[NSNumber numberWithBool:success] forKey:[completedURL absoluteString]]; - dispatch_group_leave(group); } + dispatch_group_leave(group); + dispatch_semaphore_signal(semaphore); // Signal that a slot is free }]; continue; } @@ -78,8 +95,9 @@ - (void)downloadFiles:(nonnull NSArray *)urls } dispatch_async(concurrentQueue, ^{ [self downloadSingleFile:url completed:^(BOOL success) { - [filesDownloadStatus setObject:[NSNumber numberWithBool:success] forKey:[url absoluteString]]; - + @synchronized (filesDownloadStatus) { + [filesDownloadStatus setObject:[NSNumber numberWithBool:success] forKey:[url absoluteString]]; + } // Call the other completion handlers for this file url if present NSArray *handlers; @synchronized (self) { @@ -90,15 +108,21 @@ - (void)downloadFiles:(nonnull NSArray *)urls for (DownloadCompletionHandler handler in handlers) { handler(url, success); } + dispatch_group_leave(group); + dispatch_semaphore_signal(semaphore); // Signal that a slot is free }]; }); } else { - // Add the file url to callback as success true as it is already present - [filesDownloadStatus setObject:@1 forKey:[url absoluteString]]; + @synchronized (filesDownloadStatus) { + // Add the file url to callback as success true as it is already present + [filesDownloadStatus setObject:@1 forKey:[url absoluteString]]; + } dispatch_group_leave(group); + dispatch_semaphore_signal(semaphore); // Signal that a slot is free } } + dispatch_group_notify(group, concurrentQueue, ^{ // Callback when all files are downloaded with their success status completion(filesDownloadStatus); @@ -132,13 +156,17 @@ - (void)deleteFiles:(NSArray *)urls withCompletionBlock:(CTFilesDele dispatch_group_enter(deleteGroup); dispatch_async(deleteConcurrentQueue, ^{ [self deleteSingleFile:url completed:^(BOOL success) { - [filesDeleteStatus setObject:[NSNumber numberWithBool:success] forKey:urlString]; + @synchronized(filesDeleteStatus) { + [filesDeleteStatus setObject:[NSNumber numberWithBool:success] forKey:urlString]; + } dispatch_group_leave(deleteGroup); }]; }); } else { - // Add the file url to callback as success true as it is already not present - [filesDeleteStatus setObject:@1 forKey:[url absoluteString]]; + @synchronized(filesDeleteStatus) { + // Add the file url to callback as success true as it is already not present + [filesDeleteStatus setObject:@1 forKey:[url absoluteString]]; + } } } dispatch_group_notify(deleteGroup, deleteConcurrentQueue, ^{ @@ -170,7 +198,10 @@ - (void)removeAllFilesWithCompletionBlock:(CTFilesRemoveCompletedBlock)completio if (!success) { CleverTapLogInternal(self.config.logLevel, @"%@ Failed to remove file %@ - %@", self, [file absoluteString], error); } - [filesDeleteStatus setObject:@(success) forKey:[file path]]; + // Synchronize access to the dictionary + @synchronized (filesDeleteStatus) { + [filesDeleteStatus setObject:@(success) forKey:[file path]]; + } dispatch_group_leave(deleteGroup); }); } diff --git a/CleverTapSDKTests/FileDownload/CTFileDownloadManager+Tests.h b/CleverTapSDKTests/FileDownload/CTFileDownloadManager+Tests.h index 860aceef..ffcc10dd 100644 --- a/CleverTapSDKTests/FileDownload/CTFileDownloadManager+Tests.h +++ b/CleverTapSDKTests/FileDownload/CTFileDownloadManager+Tests.h @@ -15,6 +15,9 @@ @property (nonatomic, strong) NSURLSession *session; @property (nonatomic, strong) NSFileManager* fileManager; +@property NSTimeInterval semaphoreTimeout; + +- (instancetype)initWithConfig:(CleverTapInstanceConfig *)config; - (void)downloadSingleFile:(NSURL *)url completed:(void(^)(BOOL success))completedBlock; diff --git a/CleverTapSDKTests/FileDownload/CTFileDownloadManagerTests.m b/CleverTapSDKTests/FileDownload/CTFileDownloadManagerTests.m index 0ade5945..2d74fa55 100644 --- a/CleverTapSDKTests/FileDownload/CTFileDownloadManagerTests.m +++ b/CleverTapSDKTests/FileDownload/CTFileDownloadManagerTests.m @@ -5,6 +5,7 @@ #import "CTConstants.h" #import "CTFileDownloadTestHelper.h" #import "NSFileManagerMock.h" +#import "NSURLSessionMock.h" @interface CTFileDownloadManagerTests : XCTestCase @@ -23,7 +24,7 @@ - (void)setUp { self.helper = [CTFileDownloadTestHelper new]; [self.helper addHTTPStub]; self.config = [[CleverTapInstanceConfig alloc] initWithAccountId:@"testAccountId" accountToken:@"testAccountToken"]; - self.fileDownloadManager = [CTFileDownloadManager sharedInstanceWithConfig:self.config]; + self.fileDownloadManager = [[CTFileDownloadManager alloc] initWithConfig:self.config]; } - (void)tearDown { @@ -307,6 +308,29 @@ - (void)testDownloadFilesOneUrlTimeOut { [HTTPStubs removeStub:stub]; } +- (void)testSemaphoreTimeout { + XCTestExpectation *expectation = [self expectationWithDescription:@"Semaphore Timeout Test"]; + // Generate URLs more than the max concurrency count CLTAP_FILE_MAX_CONCURRENCY_COUNT + self.fileURLs = [self.helper generateFileURLs:15]; + + // Set mock session + NSURLSessionMock *mockSession = [[NSURLSessionMock alloc] init]; + mockSession.delayInterval = 0.3; // Simulate a delay longer than semaphore timeout + self.fileDownloadManager.semaphoreTimeout = 0.1; + self.fileDownloadManager.session = mockSession; + + [self.fileDownloadManager downloadFiles:self.fileURLs withCompletionBlock:^(NSDictionary * _Nonnull fileDownloadStatus) { + for (NSURL *url in self.fileURLs) { + NSNumber *status = fileDownloadStatus[url.absoluteString]; + XCTAssertNotNil(status, @"File download status should not be nil."); + XCTAssertEqual([status integerValue], 0, @"File download should fail due to semaphore timeout."); + } + [expectation fulfill]; + }]; + + [self waitForExpectations:@[expectation] timeout:2.0]; +} + - (void)testDownloadSingle { self.fileURLs = [self.helper generateFileURLs:1]; XCTestExpectation *expectation1 = [self expectationWithDescription:@"Download files callback 1"]; diff --git a/CleverTapSDKTests/FileDownload/NSURLSessionMock.h b/CleverTapSDKTests/FileDownload/NSURLSessionMock.h new file mode 100644 index 00000000..f0818770 --- /dev/null +++ b/CleverTapSDKTests/FileDownload/NSURLSessionMock.h @@ -0,0 +1,29 @@ +// +// NSURLSessionMock.h +// CleverTapSDKTests +// +// Created by Nikola Zagorchev on 30.07.24. +// Copyright © 2024 CleverTap. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSURLSessionMock : NSURLSession + +@property (nonatomic, assign) NSTimeInterval delayInterval; + +@end + +@interface NSURLSessionDownloadTaskMock : NSURLSessionDownloadTask + +@property (nonatomic, copy) void (^completionHandler)(NSURL * _Nullable, NSURLResponse * _Nullable, NSError * _Nullable); +@property (nonatomic, assign) NSTimeInterval delayInterval; + +- (instancetype)initWithCompletionHandler:(void (^)(NSURL *, NSURLResponse *, NSError *))completionHandler + delayInterval:(NSTimeInterval)delayInterval; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CleverTapSDKTests/FileDownload/NSURLSessionMock.m b/CleverTapSDKTests/FileDownload/NSURLSessionMock.m new file mode 100644 index 00000000..43f41d4a --- /dev/null +++ b/CleverTapSDKTests/FileDownload/NSURLSessionMock.m @@ -0,0 +1,41 @@ +// +// NSURLSessionMock.m +// CleverTapSDKTests +// +// Created by Nikola Zagorchev on 30.07.24. +// Copyright © 2024 CleverTap. All rights reserved. +// + +#import "NSURLSessionMock.h" + +@implementation NSURLSessionMock + +- (NSURLSessionDownloadTask *)downloadTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSURL * _Nullable, NSURLResponse * _Nullable, NSError * _Nullable))completionHandler { + return [[NSURLSessionDownloadTaskMock alloc] initWithCompletionHandler:completionHandler delayInterval:self.delayInterval]; +} + +@end + +@implementation NSURLSessionDownloadTaskMock + +- (instancetype)initWithCompletionHandler:(void (^)(NSURL *, NSURLResponse *, NSError *))completionHandler + delayInterval:(NSTimeInterval)delayInterval { + self = [super init]; + if (self) { + _completionHandler = [completionHandler copy]; + _delayInterval = delayInterval; + } + return self; +} + +- (void)resume { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.delayInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (self.completionHandler) { + self.completionHandler(nil, nil, [NSError errorWithDomain:@"MockErrorDomain" code:-1001 userInfo:@{NSLocalizedDescriptionKey: @"Timeout"}]); + } + }); +} + +@end + +